Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 246c73a3d3 | |||
| 3d95c959f7 | |||
| e0737006c2 | |||
| 70dc9a76a9 | |||
| ba38b77387 | |||
| 1b4d263d6e | |||
| 97a3aa691d | |||
| 0ab7c44981 | |||
| 75a6a593dc | |||
| 45f0387526 | |||
| 851c9c4080 | |||
| a73f9a6d87 | |||
| 77a9faac88 | |||
| 0c00114cc8 | |||
| 9c5fbda374 | |||
| 85147e1569 | |||
| 29a846c562 | |||
| 3c5f43ecc2 | |||
| 68ded534a4 | |||
| 69b98f9e67 | |||
| 1b0386bf78 | |||
| aa6f9210b4 | |||
| 3be6a16d90 | |||
| fdb4cb91bf | |||
| 4b69f71ddb | |||
| 44020541e8 | |||
| 5869755c74 | |||
| 5b62d2142e | |||
| 575cf7ca79 | |||
| bfd347a52c | |||
| 7ab836d0f3 | |||
| d589b15123 | |||
| f48f3d0c8e | |||
| 2f7e66ee69 | |||
| 0cffbcd1f8 | |||
| 64b4da4035 | |||
| 3d2285e7af | |||
| 62f0aedc27 | |||
| 193e0ca189 | |||
| 4a63c077f5 | |||
| 15ee922777 | |||
| 2ea0b2c14c | |||
| 4ec1926888 | |||
| 3995e01336 | |||
| 481536ba24 | |||
| db8b9263a1 | |||
| a1f7250e90 | |||
| e4bc1b9bbe | |||
| 514889adfc | |||
| fccc85c4bb | |||
| acd59a7387 | |||
| d40b0514fd | |||
| 033d44808f | |||
| 4246e179f1 | |||
| fe8d9e0b7d | |||
| 1147c1e013 | |||
| 31ffd98166 | |||
| 7d7562d191 | |||
| d6e5a1c40b | |||
| 77794e43ce | |||
| b990f17a3e | |||
| 31b8ec6f7d | |||
| ef47597be5 |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx vite *)",
|
||||
"Bash(npx tsc *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -42,3 +42,5 @@ tmp/
|
||||
.tmp/
|
||||
|
||||
nodewarden.wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
|
||||
|
Before Width: | Height: | Size: 472 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg width="960" height="180" viewBox="0 0 1240 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(0 30) scale(0.276)">
|
||||
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
|
||||
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
|
||||
</g>
|
||||
<g transform="translate(225 50) scale(0.112)" fill="#116FF9">
|
||||
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z"/>
|
||||
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z"/>
|
||||
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z"/>
|
||||
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z"/>
|
||||
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z"/>
|
||||
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z"/>
|
||||
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z"/>
|
||||
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z"/>
|
||||
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z"/>
|
||||
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -1,23 +1,29 @@
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端。
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
</p>
|
||||
|
||||
[](https://workers.cloudflare.com/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
||||
</p>
|
||||
|
||||
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
<p align="center">
|
||||
<a href="./RELEASE_NOTES.md">更新日志</a> |
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">提交问题</a> |
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">最新发布</a><br />
|
||||
<a href="./nodewarden.wiki/Home.md">文档首页</a> |
|
||||
<a href="./nodewarden.wiki/快速开始.md">快速开始</a><br />
|
||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a><br />
|
||||
</p>
|
||||
|
||||
[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md)
|
||||
|
||||
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
|
||||
|
||||
English: [`README_EN.md`](./README_EN.md)
|
||||
English: <a href="./README_EN.md"><code>README_EN.md</code></a>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
||||
Bitwarden-compatible server running on Cloudflare Workers
|
||||
|
||||
</p>
|
||||
[](https://workers.cloudflare.com/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
|
||||
中文说明:[`README.md`](./README.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./RELEASE_NOTES.md">Release Notes</a> |
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">Report an Issue</a> |
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">Latest Release</a><br />
|
||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a><br />
|
||||
|
||||
</p>
|
||||
|
||||
中文说明:<a href="./README.md"><code>README.md</code></a>
|
||||
|
||||
> **Disclaimer**
|
||||
>
|
||||
|
||||
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
||||
totp_secret TEXT,
|
||||
totp_recovery_code TEXT,
|
||||
api_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -60,6 +61,7 @@ CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -181,14 +183,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
||||
identifier TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
PRIMARY KEY (identifier, window_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
||||
jti TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.4",
|
||||
"version": "1.5.1",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -9,9 +9,14 @@
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||
"build": "vite build --config webapp/vite.config.ts",
|
||||
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||
"i18n": "node scripts/i18n-validate.cjs",
|
||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
|
||||
"deploy:kv": "wrangler deploy -c wrangler.kv.toml",
|
||||
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||
},
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
@@ -40,6 +45,10 @@
|
||||
"@cloudflare/workers-types": "^4.20260131.0",
|
||||
"@preact/preset-vite": "^2.10.3",
|
||||
"@types/node": "^25.2.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"opencc-js": "^1.0.5",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
|
||||
|
||||
const localeFiles = [
|
||||
['en', 'en.ts', 'en', 'English'],
|
||||
['zh-CN', 'zh-CN.ts', 'zhCN', 'Simplified Chinese'],
|
||||
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||
['es', 'es.ts', 'es', 'Spanish'],
|
||||
];
|
||||
|
||||
function readLocale(fileName, variableName) {
|
||||
let code = fs.readFileSync(path.join(localeDir, fileName), 'utf8');
|
||||
code = code
|
||||
.replace(/const (\w+): Record<string, string> =/g, 'const $1 =')
|
||||
.replace(/export default \w+;\s*$/m, '');
|
||||
code += `\nresult = ${variableName};`;
|
||||
const sandbox = { result: null };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox, { filename: fileName });
|
||||
return sandbox.result;
|
||||
}
|
||||
|
||||
function writeLocale(fileName, variableName, table, header) {
|
||||
const body = JSON.stringify(table, null, 2);
|
||||
fs.writeFileSync(
|
||||
path.join(localeDir, fileName),
|
||||
`${header}\nconst ${variableName}: Record<string, string> = ${body};\n\nexport default ${variableName};\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
localeFiles,
|
||||
localeDir,
|
||||
readLocale,
|
||||
writeLocale,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
||||
|
||||
const locales = Object.fromEntries(
|
||||
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
|
||||
);
|
||||
const base = locales.en;
|
||||
const baseKeys = Object.keys(base).sort();
|
||||
const placeholderRe = /\{\w+\}/g;
|
||||
const errors = [];
|
||||
const intentionallyEnglishKeys = new Set([
|
||||
'txt_backup_destination_detail_note',
|
||||
'txt_backup_protocol_webdav',
|
||||
'txt_backup_protocol_s3',
|
||||
'txt_backup_recommend_group_webdav',
|
||||
'txt_backup_recommend_group_s3',
|
||||
'txt_backup_destination_name_default_webdav',
|
||||
'txt_backup_destination_name_default_s3',
|
||||
'txt_dash',
|
||||
'txt_text_3',
|
||||
]);
|
||||
|
||||
for (const [locale, table] of Object.entries(locales)) {
|
||||
const keys = Object.keys(table).sort();
|
||||
const missing = baseKeys.filter((key) => !(key in table));
|
||||
const extra = keys.filter((key) => !baseKeys.includes(key));
|
||||
if (missing.length || extra.length) {
|
||||
errors.push({ locale, missing, extra });
|
||||
}
|
||||
|
||||
for (const key of baseKeys) {
|
||||
const basePlaceholders = Array.from(String(base[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||
const localePlaceholders = Array.from(String(table[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||
if (basePlaceholders !== localePlaceholders) {
|
||||
errors.push({ locale, key, basePlaceholders, localePlaceholders });
|
||||
}
|
||||
}
|
||||
|
||||
if (locale !== 'en') {
|
||||
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !intentionallyEnglishKeys.has(key));
|
||||
if (sameAsEnglish.length > 40) {
|
||||
errors.push({
|
||||
locale,
|
||||
sameAsEnglishCount: sameAsEnglish.length,
|
||||
sameAsEnglishSample: sameAsEnglish.slice(0, 25),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
counts: Object.fromEntries(Object.entries(locales).map(([locale, table]) => [locale, Object.keys(table).length])),
|
||||
errors,
|
||||
}, null, 2));
|
||||
|
||||
if (errors.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const distDir = path.resolve(__dirname, '..', 'dist');
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.4.4';
|
||||
export const APP_VERSION = '1.5.1';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
||||
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||
|
||||
export type BackupDestinationType = 'e3' | 'webdav';
|
||||
export type BackupDestinationType = 's3' | 'webdav';
|
||||
|
||||
export interface E3BackupDestination {
|
||||
export interface S3BackupDestination {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
@@ -24,7 +24,7 @@ export interface WebDavBackupDestination {
|
||||
}
|
||||
|
||||
export type BackupDestinationConfig =
|
||||
| E3BackupDestination
|
||||
| S3BackupDestination
|
||||
| WebDavBackupDestination;
|
||||
|
||||
export interface BackupRuntimeState {
|
||||
@@ -91,11 +91,11 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
|
||||
}
|
||||
|
||||
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
|
||||
if (type === 'e3') {
|
||||
if (type === 's3') {
|
||||
return {
|
||||
endpoint: '',
|
||||
bucket: '',
|
||||
region: BACKUP_DEFAULT_E3_REGION,
|
||||
region: BACKUP_DEFAULT_S3_REGION,
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||
@@ -110,7 +110,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
||||
}
|
||||
|
||||
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
|
||||
if (type === 'e3') return `E3 ${index}`;
|
||||
if (type === 's3') return `S3 ${index}`;
|
||||
return `WebDAV ${index}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||
defaultKdfIterations: 600000,
|
||||
// clientSecret length
|
||||
// clientSecret 长度
|
||||
clientSecretLength: 30,
|
||||
},
|
||||
rateLimit: {
|
||||
// Max failed login attempts before temporary lock.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { DurableObject, waitUntil } from 'cloudflare:workers';
|
||||
import type { Env } from '../types';
|
||||
|
||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||
@@ -362,21 +362,21 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyUserVaultSync(
|
||||
export function notifyUserVaultSync(
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string,
|
||||
contextId?: string | null
|
||||
): Promise<void> {
|
||||
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null);
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
|
||||
}
|
||||
|
||||
export async function notifyUserLogout(
|
||||
export function notifyUserLogout(
|
||||
env: Env,
|
||||
userId: string,
|
||||
targetDeviceIdentifier?: string | null
|
||||
): Promise<void> {
|
||||
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null);
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null));
|
||||
}
|
||||
|
||||
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
|
||||
|
||||
@@ -209,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
verifyDevices: true,
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
apiKey: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -751,3 +752,74 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// POST /api/accounts/api-key
|
||||
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
return apiKey(request, env, userId, false);
|
||||
}
|
||||
|
||||
// POST /api/accounts/rotate-api-key
|
||||
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
return apiKey(request, env, userId, true);
|
||||
}
|
||||
|
||||
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, string | undefined>;
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||
} else {
|
||||
body = await request.json();
|
||||
}
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
|
||||
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
|
||||
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||
if (!valid) return errorResponse('Invalid password', 400);
|
||||
|
||||
if (rotate || user.apiKey === null) {
|
||||
// Upstream apikeys are 30-character random alphanumeric strings
|
||||
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
if (rotate) {
|
||||
user.securityStamp = generateUUID();
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
apiKey: user.apiKey,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'apiKey',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
|
||||
function randomStringAlphanum(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
const maxUnbiased = Math.floor(256 / chars.length) * chars.length;
|
||||
const bytes = new Uint8Array(Math.max(16, length));
|
||||
|
||||
while (result.length < length) {
|
||||
crypto.getRandomValues(bytes);
|
||||
for (const value of bytes) {
|
||||
if (value >= maxUnbiased) continue;
|
||||
result += chars[value % chars.length];
|
||||
if (result.length >= length) break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
putBlobObject,
|
||||
} from '../services/blob-store';
|
||||
|
||||
async function notifyVaultSyncForRequest(
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): Promise<void> {
|
||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
): void {
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
// Format file size to human readable
|
||||
@@ -93,7 +93,7 @@ async function processAttachmentUpload(
|
||||
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 201 });
|
||||
@@ -153,7 +153,7 @@ export async function handleCreateAttachment(
|
||||
// Update cipher revision date
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
@@ -279,6 +279,64 @@ export async function handleGetAttachment(
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
|
||||
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
|
||||
export async function handleUpdateAttachmentMetadata(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
let body: { fileName?: string | null; key?: string | null };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) {
|
||||
return errorResponse('No metadata fields supplied', 400);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'fileName')) {
|
||||
const fileName = String(body.fileName || '').trim();
|
||||
if (!fileName) return errorResponse('fileName is required', 400);
|
||||
attachment.fileName = fileName;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'key')) {
|
||||
const key = body.key == null ? null : String(body.key || '').trim();
|
||||
attachment.key = key || null;
|
||||
}
|
||||
|
||||
await storage.saveAttachment(attachment);
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment',
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key,
|
||||
size: String(Number(attachment.size) || 0),
|
||||
sizeName: attachment.sizeName,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
|
||||
// Public download endpoint (uses token for auth instead of header)
|
||||
export async function handlePublicDownloadAttachment(
|
||||
@@ -368,13 +426,10 @@ export async function handleDeleteAttachment(
|
||||
// Delete attachment metadata
|
||||
await storage.deleteAttachment(attachmentId);
|
||||
|
||||
// Remove attachment from cipher
|
||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
||||
|
||||
// Update cipher revision date
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
@@ -390,12 +445,25 @@ export async function handleDeleteAttachment(
|
||||
export async function deleteAllAttachmentsForCipher(
|
||||
env: Env,
|
||||
cipherId: string
|
||||
): Promise<void> {
|
||||
await deleteAllAttachmentsForCiphers(env, [cipherId]);
|
||||
}
|
||||
|
||||
export async function deleteAllAttachmentsForCiphers(
|
||||
env: Env,
|
||||
cipherIds: string[]
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds);
|
||||
const attachments = Array.from(attachmentsByCipher.entries()).flatMap(([ownedCipherId, items]) =>
|
||||
items.map((attachment) => ({ attachment, cipherId: ownedCipherId }))
|
||||
);
|
||||
if (!attachments.length) return;
|
||||
|
||||
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async ({ attachment, cipherId }) => {
|
||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||
await deleteBlobObject(env, path);
|
||||
await storage.deleteAttachment(attachment.id);
|
||||
});
|
||||
|
||||
await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getBackupLocalDateKey,
|
||||
getDefaultBackupSettings,
|
||||
getBackupSettingsRepairState,
|
||||
hasBackupSlotBetween,
|
||||
isBackupDueNow,
|
||||
loadBackupSettings,
|
||||
normalizeBackupSettingsInput,
|
||||
@@ -80,6 +81,98 @@ function getBackupDestinationSummary(destination: BackupDestinationRecord | null
|
||||
};
|
||||
}
|
||||
|
||||
const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1';
|
||||
const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000;
|
||||
const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000;
|
||||
|
||||
interface BackupRunnerLease {
|
||||
token: string;
|
||||
touch: () => Promise<void>;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function acquireBackupRunnerLease(env: Env, reason: string): Promise<BackupRunnerLease | null> {
|
||||
const token = generateUUID();
|
||||
const nowMs = Date.now();
|
||||
const expiresAtMs = nowMs + BACKUP_RUNNER_LEASE_MS;
|
||||
const value = JSON.stringify({
|
||||
token,
|
||||
reason,
|
||||
acquiredAt: new Date(nowMs).toISOString(),
|
||||
touchedAt: new Date(nowMs).toISOString(),
|
||||
expiresAtMs,
|
||||
});
|
||||
const result = await env.DB
|
||||
.prepare(
|
||||
`INSERT INTO config(key, value) VALUES(?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
WHERE COALESCE(CAST(json_extract(config.value, '$.expiresAtMs') AS INTEGER), 0) <= ?`
|
||||
)
|
||||
.bind(BACKUP_RUNNER_LOCK_KEY, value, nowMs)
|
||||
.run();
|
||||
|
||||
if ((result.meta?.changes || 0) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
touch: async () => {
|
||||
const nextNowMs = Date.now();
|
||||
const nextValue = JSON.stringify({
|
||||
token,
|
||||
reason,
|
||||
acquiredAt: new Date(nowMs).toISOString(),
|
||||
touchedAt: new Date(nextNowMs).toISOString(),
|
||||
expiresAtMs: nextNowMs + BACKUP_RUNNER_LEASE_MS,
|
||||
});
|
||||
await env.DB
|
||||
.prepare(
|
||||
`UPDATE config
|
||||
SET value = ?
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.token') = ?`
|
||||
)
|
||||
.bind(nextValue, BACKUP_RUNNER_LOCK_KEY, token)
|
||||
.run();
|
||||
},
|
||||
release: async () => {
|
||||
await env.DB
|
||||
.prepare(
|
||||
`DELETE FROM config
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.token') = ?`
|
||||
)
|
||||
.bind(BACKUP_RUNNER_LOCK_KEY, token)
|
||||
.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withBackupRunnerLease<T>(
|
||||
env: Env,
|
||||
reason: string,
|
||||
task: (keepAlive: () => Promise<void>) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
const lease = await acquireBackupRunnerLease(env, reason);
|
||||
if (!lease) return null;
|
||||
|
||||
let lastHeartbeatAt = 0;
|
||||
const keepAlive = async () => {
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - lastHeartbeatAt < BACKUP_RUNNER_HEARTBEAT_MS) return;
|
||||
lastHeartbeatAt = nowMs;
|
||||
await lease.touch();
|
||||
};
|
||||
|
||||
try {
|
||||
await keepAlive();
|
||||
return await task(keepAlive);
|
||||
} finally {
|
||||
await lease.release();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBackupBlobName(value: string): string {
|
||||
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
if (!normalized) {
|
||||
@@ -160,6 +253,7 @@ async function executeConfiguredBackup(
|
||||
actorUserId: string | null,
|
||||
trigger: 'manual' | 'scheduled',
|
||||
destinationId?: string | null,
|
||||
keepAlive?: (() => Promise<void>) | null,
|
||||
progress?: ((event: {
|
||||
operation: 'backup-remote-run';
|
||||
step: string;
|
||||
@@ -172,6 +266,9 @@ async function executeConfiguredBackup(
|
||||
}) => Promise<void>) | null
|
||||
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||
const maxArchiveUploadAttempts = 3;
|
||||
const touchLease = async () => {
|
||||
await keepAlive?.();
|
||||
};
|
||||
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||
|
||||
@@ -180,9 +277,11 @@ async function executeConfiguredBackup(
|
||||
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||
destination.runtime.lastErrorAt = null;
|
||||
destination.runtime.lastErrorMessage = null;
|
||||
await touchLease();
|
||||
await saveBackupSettings(storage, env, currentSettings);
|
||||
|
||||
try {
|
||||
await touchLease();
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_prepare',
|
||||
@@ -190,6 +289,7 @@ async function executeConfiguredBackup(
|
||||
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
|
||||
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
|
||||
});
|
||||
await touchLease();
|
||||
const archive = await buildBackupArchive(env, now, {
|
||||
includeAttachments: destination.includeAttachments,
|
||||
timeZone: destination.schedule.timezone,
|
||||
@@ -219,9 +319,11 @@ async function executeConfiguredBackup(
|
||||
});
|
||||
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||
if (destination.includeAttachments) {
|
||||
await touchLease();
|
||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||
let attachmentIndexChanged = false;
|
||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||
await touchLease();
|
||||
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||
continue;
|
||||
}
|
||||
@@ -238,11 +340,13 @@ async function executeConfiguredBackup(
|
||||
attachmentIndexChanged = true;
|
||||
}
|
||||
if (attachmentIndexChanged) {
|
||||
await touchLease();
|
||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||
}
|
||||
}
|
||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||
await touchLease();
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_upload_archive',
|
||||
@@ -252,6 +356,7 @@ async function executeConfiguredBackup(
|
||||
});
|
||||
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
|
||||
try {
|
||||
await touchLease();
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_verify_archive',
|
||||
@@ -282,6 +387,7 @@ async function executeConfiguredBackup(
|
||||
let prunedFileCount = 0;
|
||||
let pruneErrorMessage: string | null = null;
|
||||
try {
|
||||
await touchLease();
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_cleanup',
|
||||
@@ -300,8 +406,10 @@ async function executeConfiguredBackup(
|
||||
destination.runtime.lastUploadedFileName = archive.fileName;
|
||||
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
||||
destination.runtime.lastUploadedDestination = upload.remotePath;
|
||||
await touchLease();
|
||||
await saveBackupSettings(storage, env, currentSettings);
|
||||
|
||||
await touchLease();
|
||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||
...getBackupDestinationSummary(destination),
|
||||
provider: upload.provider,
|
||||
@@ -332,8 +440,10 @@ async function executeConfiguredBackup(
|
||||
} catch (error) {
|
||||
destination.runtime.lastErrorAt = new Date().toISOString();
|
||||
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||
await touchLease();
|
||||
await saveBackupSettings(storage, env, currentSettings);
|
||||
|
||||
await touchLease();
|
||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||
...getBackupDestinationSummary(destination),
|
||||
error: destination.runtime.lastErrorMessage,
|
||||
@@ -404,13 +514,30 @@ async function runImportAndAudit(
|
||||
}
|
||||
|
||||
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
||||
await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
|
||||
const storage = new StorageService(env.DB);
|
||||
let scanStartMs = Date.now();
|
||||
|
||||
while (true) {
|
||||
await keepAlive();
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const now = new Date();
|
||||
for (const destination of settings.destinations) {
|
||||
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue;
|
||||
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id);
|
||||
const dueDestinations = settings.destinations.filter((destination) =>
|
||||
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|
||||
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
|
||||
);
|
||||
|
||||
if (!dueDestinations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
scanStartMs = now.getTime();
|
||||
for (const destination of dueDestinations) {
|
||||
await keepAlive();
|
||||
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id, keepAlive);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
@@ -512,7 +639,6 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
|
||||
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
try {
|
||||
let body: { destinationId?: string } | null = null;
|
||||
try {
|
||||
@@ -536,17 +662,32 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
||||
}) => {
|
||||
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
||||
};
|
||||
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
|
||||
const outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => {
|
||||
const storage = new StorageService(env.DB);
|
||||
const result = await executeConfiguredBackup(
|
||||
env,
|
||||
storage,
|
||||
actorUser.id,
|
||||
'manual',
|
||||
body?.destinationId || null,
|
||||
keepAlive,
|
||||
progress
|
||||
);
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
return { result, settings };
|
||||
});
|
||||
if (!outcome) {
|
||||
return errorResponse('Another backup run is already in progress', 409);
|
||||
}
|
||||
return jsonResponse({
|
||||
object: 'backup-run',
|
||||
result: {
|
||||
fileName: result.fileName,
|
||||
fileSize: result.fileSize,
|
||||
provider: result.provider,
|
||||
remotePath: result.remotePath,
|
||||
fileName: outcome.result.fileName,
|
||||
fileSize: outcome.result.fileSize,
|
||||
provider: outcome.result.provider,
|
||||
remotePath: outcome.result.remotePath,
|
||||
},
|
||||
settings,
|
||||
settings: outcome.settings,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { StorageService } from '../services/storage';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { deleteAllAttachmentsForCipher } from './attachments';
|
||||
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
|
||||
@@ -24,13 +24,13 @@ function normalizeOptionalId(value: unknown): string | null {
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
async function notifyVaultSyncForRequest(
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): Promise<void> {
|
||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
): void {
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
||||
@@ -78,6 +78,43 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||
return cipher;
|
||||
}
|
||||
|
||||
function isValidEncString(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
const dot = trimmed.indexOf('.');
|
||||
if (dot <= 0) return false;
|
||||
const type = Number(trimmed.slice(0, dot));
|
||||
if (!Number.isInteger(type) || type < 0) return false;
|
||||
const parts = trimmed.slice(dot + 1).split('|');
|
||||
if (parts.some((part) => part.length === 0)) return false;
|
||||
|
||||
// Bitwarden's legacy symmetric EncString variants require IV + data,
|
||||
// while the authenticated AES-CBC-HMAC variant requires IV + data + MAC.
|
||||
if (type === 0 || type === 1 || type === 4) return parts.length >= 2;
|
||||
if (type === 2) return parts.length === 3;
|
||||
|
||||
// Keep newer one-part formats, such as COSE Encrypt0, future-compatible.
|
||||
return parts.length >= 1;
|
||||
}
|
||||
|
||||
function optionalEncString(value: unknown): string | null {
|
||||
if (value == null || value === '') return null;
|
||||
return isValidEncString(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeEncryptedObject<T extends Record<string, any>>(
|
||||
source: T | null | undefined,
|
||||
encryptedKeys: readonly string[]
|
||||
): T | null {
|
||||
if (!source || typeof source !== 'object') return source ?? null;
|
||||
const next: Record<string, any> = { ...source };
|
||||
for (const key of encryptedKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
|
||||
next[key] = optionalEncString(next[key]);
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
|
||||
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
@@ -100,7 +137,53 @@ export function normalizeCipherLoginForStorage(login: any): any {
|
||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||
const normalized = normalizeCipherLoginForStorage(login);
|
||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||
return normalized;
|
||||
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
|
||||
if (!next) return null;
|
||||
next.uris = Array.isArray(next.uris)
|
||||
? next.uris
|
||||
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']))
|
||||
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null))
|
||||
: null;
|
||||
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||
const requiredEncryptedKeys = [
|
||||
'credentialId',
|
||||
'keyType',
|
||||
'keyAlgorithm',
|
||||
'keyCurve',
|
||||
'keyValue',
|
||||
'rpId',
|
||||
'counter',
|
||||
'discoverable',
|
||||
];
|
||||
const optionalEncryptedKeys = ['userHandle', 'userName', 'rpName', 'userDisplayName'];
|
||||
const out: any[] = [];
|
||||
|
||||
for (const credential of credentials) {
|
||||
if (!credential || typeof credential !== 'object') continue;
|
||||
const next: Record<string, any> = { ...credential };
|
||||
let valid = true;
|
||||
for (const key of requiredEncryptedKeys) {
|
||||
if (!isValidEncString(next[key])) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
next[key] = String(next[key]).trim();
|
||||
}
|
||||
if (!valid) continue;
|
||||
for (const key of optionalEncryptedKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(next, key)) {
|
||||
next[key] = optionalEncString(next[key]);
|
||||
}
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||
@@ -118,8 +201,18 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
||||
? ''
|
||||
: String(candidate);
|
||||
|
||||
if (
|
||||
!isValidEncString(sshKey.privateKey) ||
|
||||
!isValidEncString(sshKey.publicKey) ||
|
||||
!isValidEncString(normalizedFingerprint)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...sshKey,
|
||||
privateKey: String(sshKey.privateKey).trim(),
|
||||
publicKey: String(sshKey.publicKey).trim(),
|
||||
keyFingerprint: normalizedFingerprint,
|
||||
fingerprint: normalizedFingerprint,
|
||||
};
|
||||
@@ -128,16 +221,52 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
||||
// Format attachments for API response
|
||||
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
if (attachments.length === 0) return null;
|
||||
return attachments.map(a => ({
|
||||
const formatted = attachments
|
||||
.filter((a) => isValidEncString(a.fileName))
|
||||
.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
fileName: a.fileName.trim(),
|
||||
// Bitwarden clients decode attachment size as string in cipher payloads.
|
||||
size: String(Number(a.size) || 0),
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
key: optionalEncString(a.key),
|
||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
||||
object: 'attachment',
|
||||
}));
|
||||
return formatted.length ? formatted : null;
|
||||
}
|
||||
|
||||
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
|
||||
if (!Array.isArray(fields) || fields.length === 0) return null;
|
||||
const out = fields
|
||||
.map((field: any) => {
|
||||
if (!field || typeof field !== 'object') return null;
|
||||
return {
|
||||
...field,
|
||||
name: optionalEncString(field.name),
|
||||
value: optionalEncString(field.value),
|
||||
type: Number(field.type) || 0,
|
||||
linkedId: field.linkedId ?? null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
function normalizePasswordHistoryForCompatibility(passwordHistory: any): PasswordHistory[] | null {
|
||||
if (!Array.isArray(passwordHistory) || passwordHistory.length === 0) return null;
|
||||
const out = passwordHistory
|
||||
.filter((entry: any) => entry && typeof entry === 'object' && isValidEncString(entry.password))
|
||||
.map((entry: any) => ({
|
||||
...entry,
|
||||
password: String(entry.password).trim(),
|
||||
lastUsedDate: normalizeCipherTimestamp(entry.lastUsedDate) ?? new Date().toISOString(),
|
||||
}));
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean {
|
||||
return isValidEncString(cipher.name);
|
||||
}
|
||||
|
||||
// Convert internal cipher to API response format.
|
||||
@@ -151,6 +280,27 @@ export function cipherToResponse(
|
||||
// Strip internal-only fields that must not appear in the API response
|
||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
||||
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
||||
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
||||
'title',
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'address1',
|
||||
'address2',
|
||||
'address3',
|
||||
'city',
|
||||
'state',
|
||||
'postalCode',
|
||||
'country',
|
||||
'company',
|
||||
'email',
|
||||
'phone',
|
||||
'ssn',
|
||||
'username',
|
||||
'passportNumber',
|
||||
'licenseNumber',
|
||||
]);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
|
||||
return {
|
||||
@@ -174,8 +324,15 @@ export function cipherToResponse(
|
||||
object: 'cipherDetails',
|
||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||
attachments: formatAttachments(attachments),
|
||||
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
|
||||
notes: optionalEncString(cipher.notes),
|
||||
login: normalizedLogin,
|
||||
card: normalizedCard,
|
||||
identity: normalizedIdentity,
|
||||
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||
sshKey: normalizedSshKey,
|
||||
key: optionalEncString(cipher.key),
|
||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||
};
|
||||
}
|
||||
@@ -304,7 +461,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, []),
|
||||
@@ -398,7 +555,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
|
||||
return jsonResponse(
|
||||
@@ -421,7 +578,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
syncCipherComputedAliases(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
@@ -445,7 +602,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
||||
await deleteAllAttachmentsForCipher(env, id);
|
||||
await storage.deleteCipher(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -466,7 +623,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
||||
|
||||
await storage.deleteCipher(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -485,7 +642,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
syncCipherComputedAliases(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
@@ -524,7 +681,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
@@ -554,7 +711,7 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
|
||||
|
||||
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
@@ -600,7 +757,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
||||
normalizeCipherForStorage(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
@@ -622,7 +779,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
|
||||
normalizeCipherForStorage(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
@@ -648,7 +805,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
|
||||
|
||||
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return buildCipherListResponse(request, storage, userId, ids);
|
||||
@@ -672,7 +829,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use
|
||||
|
||||
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return buildCipherListResponse(request, storage, userId, ids);
|
||||
@@ -695,7 +852,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
|
||||
|
||||
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
@@ -718,7 +875,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
|
||||
|
||||
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
@@ -744,13 +901,17 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
await deleteAllAttachmentsForCipher(env, id);
|
||||
const ownedCiphers = await storage.getCiphersByIds(ids, userId);
|
||||
const ownedIds = ownedCiphers.map((cipher) => cipher.id);
|
||||
if (!ownedIds.length) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkDeleteCiphers(ids, userId);
|
||||
await deleteAllAttachmentsForCiphers(env, ownedIds);
|
||||
|
||||
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
|
||||
@@ -284,7 +284,7 @@ export async function handleDeleteDevice(
|
||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||
const deleted = await storage.deleteDevice(userId, normalized);
|
||||
if (deleted) {
|
||||
await notifyUserLogout(env, userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
@@ -327,7 +327,7 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await notifyUserLogout(env, userId, null);
|
||||
notifyUserLogout(env, userId, null);
|
||||
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@ export async function handleDeactivateDevice(
|
||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||
const deleted = await storage.deleteDevice(userId, normalized);
|
||||
if (deleted) {
|
||||
await notifyUserLogout(env, userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
|
||||
async function notifyVaultSyncForRequest(
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): Promise<void> {
|
||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
): void {
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
// Convert internal folder to API response format
|
||||
@@ -21,6 +21,7 @@ function folderToResponse(folder: Folder): FolderResponse {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
creationDate: folder.createdAt,
|
||||
object: 'folder',
|
||||
};
|
||||
}
|
||||
@@ -87,7 +88,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(folderToResponse(folder), 200);
|
||||
}
|
||||
@@ -115,7 +116,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(folderToResponse(folder));
|
||||
}
|
||||
@@ -132,7 +133,7 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
||||
await storage.clearFolderFromCiphers(userId, id);
|
||||
await storage.deleteFolder(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -155,7 +156,7 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
||||
|
||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
|
||||
@@ -48,6 +48,18 @@ function parseCookieValue(request: Request, name: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
@@ -361,6 +373,98 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'client_credentials') {
|
||||
// Login with client credentials
|
||||
const clientId = body.client_id;
|
||||
const clientSecret = body.client_secret;
|
||||
const scope = body.scope;
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
const loginIdentifier = `${clientIdentifier}:${clientId}`;
|
||||
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||
if (!parmValid) {
|
||||
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
if (!loginCheck.allowed) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
const uid = clientId.slice(5);
|
||||
const user = await storage.getUserById(uid);
|
||||
if (!user) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// Persist device only after successful client credential verification.
|
||||
const deviceSession =
|
||||
deviceInfo.deviceIdentifier
|
||||
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||
: null;
|
||||
if (deviceSession) {
|
||||
await storage.upsertDevice(
|
||||
user.id,
|
||||
deviceSession.identifier,
|
||||
deviceInfo.deviceName,
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'send_access') {
|
||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||
if (!sendAccessLimit.allowed) {
|
||||
@@ -553,3 +657,16 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
||||
? withWebRefreshCookie(request, baseResponse, null)
|
||||
: baseResponse;
|
||||
}
|
||||
|
||||
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
|
||||
if (scope !== 'api') {
|
||||
return false;
|
||||
}
|
||||
if (!clientId.startsWith('user.')) {
|
||||
return false;
|
||||
}
|
||||
if (!clientSecret) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
|
||||
// Update revision date
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
|
||||
if (returnCipherMap) {
|
||||
return jsonResponse({
|
||||
|
||||
@@ -76,7 +76,7 @@ async function processSendFileUpload(
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
@@ -226,7 +226,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
||||
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
@@ -349,7 +349,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
||||
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
const jwtSecret = getSafeJwtSecret(env);
|
||||
if (!jwtSecret) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
@@ -596,7 +596,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
@@ -619,7 +619,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
|
||||
|
||||
await storage.deleteSend(sendId, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -650,7 +650,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
||||
|
||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
@@ -668,7 +668,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
@@ -686,7 +686,7 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
}
|
||||
|
||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||
@@ -162,7 +162,7 @@ export async function handleAccessSendFile(
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
|
||||
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||
const url = new URL(request.url);
|
||||
@@ -202,7 +202,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
}
|
||||
|
||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||
@@ -241,7 +241,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
|
||||
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -9,13 +9,13 @@ export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer availa
|
||||
const SEND_PASSWORD_ITERATIONS = 100_000;
|
||||
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
||||
|
||||
export async function notifyVaultSyncForRequest(
|
||||
export function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): Promise<void> {
|
||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
): void {
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
|
||||
import { sendToResponse } from './sends';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
|
||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
|
||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
||||
const url = new URL(request.url);
|
||||
const cacheUrl = new URL(
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
|
||||
url.origin
|
||||
);
|
||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||
@@ -35,6 +35,8 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const url = new URL(request.url);
|
||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||
const excludeSendsParam = url.searchParams.get('excludeSends');
|
||||
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
@@ -42,7 +44,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
}
|
||||
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
|
||||
const cachedResponse = await readSyncCache(cacheRequest);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
@@ -51,7 +53,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||
storage.getAllCiphers(userId),
|
||||
storage.getAllFolders(userId),
|
||||
storage.getAllSends(userId),
|
||||
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
||||
storage.getAttachmentsByUserId(userId),
|
||||
]);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
@@ -84,7 +86,10 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
|
||||
if (isCipherResponseSyncCompatible(response)) {
|
||||
cipherResponses.push(response);
|
||||
}
|
||||
}
|
||||
|
||||
const folderResponses: FolderResponse[] = [];
|
||||
@@ -93,6 +98,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
creationDate: folder.createdAt,
|
||||
object: 'folder',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleGetApiKey,
|
||||
handleRotateApiKey,
|
||||
} from './handlers/accounts';
|
||||
import {
|
||||
handleGetCiphers,
|
||||
@@ -58,6 +60,7 @@ import {
|
||||
handleCreateAttachment,
|
||||
handleUploadAttachment,
|
||||
handleGetAttachment,
|
||||
handleUpdateAttachmentMetadata,
|
||||
handleDeleteAttachment,
|
||||
} from './handlers/attachments';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
@@ -119,6 +122,14 @@ export async function handleAuthenticatedRoute(
|
||||
return handleSetVerifyDevices(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
|
||||
return handleGetApiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
|
||||
return handleRotateApiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
@@ -191,6 +202,11 @@ export async function handleAuthenticatedRoute(
|
||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
const attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i);
|
||||
if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) {
|
||||
return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]);
|
||||
}
|
||||
|
||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||
if (attachmentDeleteMatch && method === 'POST') {
|
||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||
|
||||
@@ -52,16 +52,25 @@ function isSameOriginWriteRequest(request: Request): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNwIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
||||
function getDefaultWebsiteIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
}
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getNwIconSvg(), {
|
||||
return new Response(getDefaultWebsiteIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleMissingWebsiteIcon(): Response {
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -117,7 +126,12 @@ function buildConfigResponse(origin: string) {
|
||||
}
|
||||
|
||||
function normalizeIconHost(rawHost: string): string | null {
|
||||
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
||||
try {
|
||||
const parsed = new URL(`https://${decoded}`);
|
||||
@@ -127,9 +141,29 @@ function normalizeIconHost(rawHost: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebsiteIcon(host: string): Promise<Response> {
|
||||
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
||||
|
||||
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(source.url, {
|
||||
headers: source.headers,
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||
const normalizedHost = normalizeIconHost(host);
|
||||
if (!normalizedHost) return handleNwFavicon();
|
||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
|
||||
const encodedHost = encodeURIComponent(normalizedHost);
|
||||
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||
@@ -150,14 +184,7 @@ async function handleWebsiteIcon(host: string): Promise<Response> {
|
||||
|
||||
try {
|
||||
for (const source of upstreamSources) {
|
||||
const resp = await fetch(source.url, {
|
||||
headers: source.headers,
|
||||
redirect: 'follow',
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||
const resp = await fetchIconSource(source);
|
||||
|
||||
if (!resp.ok) continue;
|
||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||
@@ -167,14 +194,14 @@ async function handleWebsiteIcon(host: string): Promise<Response> {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return handleNwFavicon();
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
} catch {
|
||||
return handleNwFavicon();
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +248,8 @@ export async function handlePublicRoute(
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch && method === 'GET') {
|
||||
return handleWebsiteIcon(iconMatch[1]);
|
||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
||||
}
|
||||
|
||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
|
||||
@@ -6,6 +6,17 @@ import { StorageService } from './storage';
|
||||
// The client already does heavy PBKDF2 (600k iterations).
|
||||
// This second layer only needs to be non-trivial, not expensive.
|
||||
const SERVER_HASH_ITERATIONS = 100_000;
|
||||
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||
|
||||
interface CachedUserEntry {
|
||||
user: User | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface CachedDeviceEntry {
|
||||
device: Awaited<ReturnType<StorageService['getDevice']>>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface VerifiedAccessContext {
|
||||
payload: JWTPayload;
|
||||
@@ -14,11 +25,77 @@ export interface VerifiedAccessContext {
|
||||
|
||||
export class AuthService {
|
||||
private storage: StorageService;
|
||||
private static userCache = new Map<string, CachedUserEntry>();
|
||||
private static deviceCache = new Map<string, CachedDeviceEntry>();
|
||||
|
||||
constructor(private env: Env) {
|
||||
this.storage = new StorageService(env.DB);
|
||||
}
|
||||
|
||||
private readCachedUser(userId: string): User | null | undefined {
|
||||
const cached = AuthService.userCache.get(userId);
|
||||
if (!cached) return undefined;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
AuthService.userCache.delete(userId);
|
||||
return undefined;
|
||||
}
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
private writeCachedUser(userId: string, user: User | null): void {
|
||||
AuthService.userCache.set(userId, {
|
||||
user,
|
||||
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async getCachedUser(userId: string): Promise<User | null> {
|
||||
const cached = this.readCachedUser(userId);
|
||||
if (cached !== undefined) return cached;
|
||||
const user = await this.storage.getUserById(userId);
|
||||
this.writeCachedUser(userId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private async getFreshUser(userId: string): Promise<User | null> {
|
||||
const user = await this.storage.getUserById(userId);
|
||||
this.writeCachedUser(userId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private readCachedDevice(userId: string, deviceId: string) {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
const cached = AuthService.deviceCache.get(cacheKey);
|
||||
if (!cached) return undefined;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
AuthService.deviceCache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
return cached.device;
|
||||
}
|
||||
|
||||
private writeCachedDevice(userId: string, deviceId: string, device: Awaited<ReturnType<StorageService['getDevice']>>): void {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
AuthService.deviceCache.set(cacheKey, {
|
||||
device,
|
||||
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async getCachedDevice(userId: string, deviceId: string) {
|
||||
const cached = this.readCachedDevice(userId, deviceId);
|
||||
if (cached !== undefined) return cached;
|
||||
const device = await this.storage.getDevice(userId, deviceId);
|
||||
this.writeCachedDevice(userId, deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
private async getFreshDevice(userId: string, deviceId: string) {
|
||||
const device = await this.storage.getDevice(userId, deviceId);
|
||||
this.writeCachedDevice(userId, deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||
@@ -97,15 +174,22 @@ export class AuthService {
|
||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||
if (!payload) return null;
|
||||
|
||||
const user = await this.storage.getUserById(payload.sub);
|
||||
let user = await this.getCachedUser(payload.sub);
|
||||
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
|
||||
user = await this.getFreshUser(payload.sub);
|
||||
}
|
||||
if (!user) return null;
|
||||
if (user.status !== 'active') return null;
|
||||
|
||||
if (payload.sstamp !== user.securityStamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.did) {
|
||||
const device = await this.storage.getDevice(user.id, payload.did);
|
||||
let device = await this.getCachedDevice(user.id, payload.did);
|
||||
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
|
||||
device = await this.getFreshDevice(user.id, payload.did);
|
||||
}
|
||||
if (!device) return null;
|
||||
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ export async function buildBackupArchive(
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type BackupRuntimeState,
|
||||
type BackupScheduleConfig,
|
||||
type BackupSettings,
|
||||
type E3BackupDestination,
|
||||
type S3BackupDestination,
|
||||
type WebDavBackupDestination,
|
||||
createBackupRandomId,
|
||||
createDefaultBackupDestinationName,
|
||||
@@ -35,7 +35,7 @@ export type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings,
|
||||
E3BackupDestination,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '../../shared/backup-schema';
|
||||
|
||||
@@ -105,7 +105,7 @@ function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_ST
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
||||
function normalizeS3Destination(value: unknown, allowIncomplete = false): S3BackupDestination {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const endpoint = asTrimmedString(source.endpoint);
|
||||
const bucket = asTrimmedString(source.bucket);
|
||||
@@ -115,17 +115,17 @@ function normalizeE3Destination(value: unknown, allowIncomplete = false): E3Back
|
||||
const rootPath = normalizePath(source.rootPath);
|
||||
|
||||
if (!allowIncomplete || endpoint) {
|
||||
if (!endpoint) throw new Error('E3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
|
||||
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
||||
}
|
||||
if (!allowIncomplete || bucket) {
|
||||
if (!bucket) throw new Error('E3 bucket is required');
|
||||
if (!bucket) throw new Error('S3 bucket is required');
|
||||
}
|
||||
if (!allowIncomplete || accessKeyId) {
|
||||
if (!accessKeyId) throw new Error('E3 access key is required');
|
||||
if (!accessKeyId) throw new Error('S3 access key is required');
|
||||
}
|
||||
if (!allowIncomplete || secretAccessKey) {
|
||||
if (!secretAccessKey) throw new Error('E3 secret key is required');
|
||||
if (!secretAccessKey) throw new Error('S3 secret key is required');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -169,7 +169,7 @@ function normalizeDestination(
|
||||
destination: unknown,
|
||||
allowIncomplete = false
|
||||
): BackupDestinationConfig {
|
||||
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
||||
if (destinationType === 's3') return normalizeS3Destination(destination, allowIncomplete);
|
||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
}
|
||||
|
||||
@@ -204,7 +204,8 @@ function defaultDestinationName(type: BackupDestinationType, index: number): str
|
||||
|
||||
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||
const value = asTrimmedString(raw);
|
||||
if (value === 'e3' || value === 'webdav') return value;
|
||||
if (value === 'e3') return 's3';
|
||||
if (value === 's3' || value === 'webdav') return value;
|
||||
throw new Error('Backup destination type is invalid');
|
||||
}
|
||||
|
||||
@@ -266,8 +267,8 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
|
||||
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||
const destinationType: BackupDestinationType =
|
||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
||||
? destinationTypeRaw
|
||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 's3' || destinationTypeRaw === 'webdav'
|
||||
? getDestinationType(destinationTypeRaw)
|
||||
: 'webdav';
|
||||
const destination = {
|
||||
id: createBackupRandomId(),
|
||||
@@ -598,6 +599,50 @@ function getBackupSlotStartsForLocalDay(
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function hasBackupSlotBetween(
|
||||
destination: BackupDestinationRecord,
|
||||
startInclusive: Date,
|
||||
endExclusive: Date
|
||||
): boolean {
|
||||
if (!destination.schedule.enabled) return false;
|
||||
const startMs = startInclusive.getTime();
|
||||
const endMs = endExclusive.getTime();
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||
|
||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||
? lastAttemptAt.getTime()
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
|
||||
const dayCursor = new Date(startMs);
|
||||
dayCursor.setUTCHours(0, 0, 0, 0);
|
||||
const endDay = new Date(endMs);
|
||||
endDay.setUTCHours(0, 0, 0, 0);
|
||||
const checkedLocalDateKeys = new Set<string>();
|
||||
|
||||
while (dayCursor.getTime() <= endDay.getTime() + 24 * 60 * 60 * 1000) {
|
||||
const localDateKey = getBackupLocalDateKey(dayCursor, destination.schedule.timezone);
|
||||
if (!checkedLocalDateKeys.has(localDateKey)) {
|
||||
checkedLocalDateKeys.add(localDateKey);
|
||||
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||
localDateKey,
|
||||
destination.schedule.timezone,
|
||||
destination.schedule.startTime,
|
||||
destination.schedule.intervalHours
|
||||
);
|
||||
for (const slotStart of slotStarts) {
|
||||
const slotStartMs = slotStart.getTime();
|
||||
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||
if (lastAttemptMs >= slotStartMs) continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isBackupDueNow(
|
||||
destination: BackupDestinationRecord,
|
||||
now: Date,
|
||||
|
||||
@@ -594,7 +594,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('users'),
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'api_key', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
E3BackupDestination,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from './backup-config';
|
||||
|
||||
@@ -213,13 +213,13 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
||||
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||
return;
|
||||
}
|
||||
if (destination.type === 'e3') {
|
||||
const config = destination.destination as E3BackupDestination;
|
||||
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
|
||||
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
|
||||
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
|
||||
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
|
||||
if (destination.type === 's3') {
|
||||
const config = destination.destination as S3BackupDestination;
|
||||
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
|
||||
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
||||
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
||||
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,16 +448,16 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
||||
return true;
|
||||
}
|
||||
|
||||
function e3BucketBaseUrl(config: E3BackupDestination): URL {
|
||||
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||
}
|
||||
|
||||
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
|
||||
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||
}
|
||||
|
||||
async function signedE3Request(
|
||||
config: E3BackupDestination,
|
||||
async function signedS3Request(
|
||||
config: S3BackupDestination,
|
||||
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
|
||||
url: URL,
|
||||
body?: Uint8Array,
|
||||
@@ -494,41 +494,41 @@ async function signedE3Request(
|
||||
});
|
||||
}
|
||||
|
||||
async function putToE3(
|
||||
config: E3BackupDestination,
|
||||
async function putToS3(
|
||||
config: S3BackupDestination,
|
||||
relativePath: string,
|
||||
bytes: Uint8Array,
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
): Promise<void> {
|
||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType);
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`E3 upload failed: ${response.status}`);
|
||||
throw new Error(`S3 upload failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||
await putToE3(config, fileName, archive, { contentType: 'application/zip' });
|
||||
async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||
await putToS3(config, fileName, archive, { contentType: 'application/zip' });
|
||||
return {
|
||||
provider: 'e3',
|
||||
remotePath: normalizeE3ObjectKey(config, fileName),
|
||||
provider: 's3',
|
||||
remotePath: normalizeS3ObjectKey(config, fileName),
|
||||
};
|
||||
}
|
||||
|
||||
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
const currentPath = normalizeRelativePath(relativePath);
|
||||
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
|
||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||
const url = e3BucketBaseUrl(config);
|
||||
const url = s3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
|
||||
const response = await signedE3Request(config, 'GET', url);
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`E3 listing failed: ${response.status}`);
|
||||
throw new Error(`S3 listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
@@ -581,26 +581,26 @@ async function listE3Entries(config: E3BackupDestination, relativePath: string):
|
||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||
|
||||
return {
|
||||
provider: 'e3',
|
||||
provider: 's3',
|
||||
currentPath,
|
||||
parentPath: parentPath(currentPath),
|
||||
items: sortRemoteItems(Array.from(deduped.values())),
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||
async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (!normalized || normalized.endsWith('/')) {
|
||||
throw new Error('Please select a backup file');
|
||||
}
|
||||
const objectKey = normalizeE3ObjectKey(config, normalized);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'GET', url);
|
||||
const objectKey = normalizeS3ObjectKey(config, normalized);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`E3 download failed: ${response.status}`);
|
||||
throw new Error(`S3 download failed: ${response.status}`);
|
||||
}
|
||||
return {
|
||||
provider: 'e3',
|
||||
provider: 's3',
|
||||
remotePath: normalized,
|
||||
fileName: basename(normalized) || 'backup.zip',
|
||||
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||
@@ -608,35 +608,35 @@ async function downloadFromE3(config: E3BackupDestination, relativePath: string)
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
|
||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'DELETE', url);
|
||||
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedS3Request(config, 'DELETE', url);
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`E3 delete failed: ${response.status}`);
|
||||
throw new Error(`S3 delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> {
|
||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'HEAD', url);
|
||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedS3Request(config, 'HEAD', url);
|
||||
if (response.status === 404) return false;
|
||||
if (!response.ok) {
|
||||
throw new Error(`E3 existence check failed: ${response.status}`);
|
||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ConfiguredDestinationAdapter {
|
||||
provider: 'webdav' | 'e3';
|
||||
config: WebDavBackupDestination | E3BackupDestination;
|
||||
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
|
||||
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
provider: 'webdav' | 's3';
|
||||
config: WebDavBackupDestination | S3BackupDestination;
|
||||
upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||
putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||
list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteBackupTransferSession {
|
||||
@@ -666,16 +666,16 @@ function resolveConfiguredDestinationAdapter(
|
||||
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
if (destination.type === 'e3') {
|
||||
if (destination.type === 's3') {
|
||||
return {
|
||||
provider: 'e3',
|
||||
config: destination.destination as E3BackupDestination,
|
||||
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
|
||||
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options),
|
||||
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
|
||||
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
|
||||
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath),
|
||||
provider: 's3',
|
||||
config: destination.destination as S3BackupDestination,
|
||||
upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName),
|
||||
putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options),
|
||||
list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath),
|
||||
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -703,7 +703,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
|
||||
provider: adapter.provider,
|
||||
remotePath: adapter.provider === 'webdav'
|
||||
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
||||
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
|
||||
: normalizeS3ObjectKey(adapter.config as S3BackupDestination, fileName),
|
||||
};
|
||||
},
|
||||
putFile,
|
||||
|
||||
@@ -34,6 +34,22 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
|
||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
export async function bulkDeleteAttachmentsByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
attachmentIds: string[]
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(attachmentIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
||||
if (!uniqueIds.length) return;
|
||||
const chunkSize = sqlChunkSize(0);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...chunk).run();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||
const res = await db
|
||||
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||
@@ -119,11 +135,6 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
|
||||
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||
}
|
||||
|
||||
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
void cipherId;
|
||||
void attachmentId;
|
||||
}
|
||||
|
||||
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,43 @@ interface CipherRow {
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||
'id',
|
||||
'userId',
|
||||
'user_id',
|
||||
'type',
|
||||
'folderId',
|
||||
'folder_id',
|
||||
'name',
|
||||
'notes',
|
||||
'favorite',
|
||||
'reprompt',
|
||||
'key',
|
||||
'createdAt',
|
||||
'created_at',
|
||||
'creationDate',
|
||||
'updatedAt',
|
||||
'updated_at',
|
||||
'revisionDate',
|
||||
'archivedAt',
|
||||
'archived_at',
|
||||
'archivedDate',
|
||||
'deletedAt',
|
||||
'deleted_at',
|
||||
'deletedDate',
|
||||
]);
|
||||
|
||||
function buildCipherData(cipher: Cipher, folderId: string | null): string {
|
||||
const payload: Record<string, unknown> = {
|
||||
...cipher,
|
||||
folderId,
|
||||
};
|
||||
for (const key of CIPHER_SCALAR_DATA_KEYS) {
|
||||
delete payload[key];
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
if (!row?.data) return null;
|
||||
try {
|
||||
@@ -68,10 +105,7 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
||||
|
||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||
const folderId = normalizeOptionalId(cipher.folderId);
|
||||
const data = JSON.stringify({
|
||||
...cipher,
|
||||
folderId,
|
||||
});
|
||||
const data = buildCipherData(cipher, folderId);
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
@@ -117,8 +151,7 @@ export async function bulkSoftDeleteCiphers(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -126,10 +159,11 @@ export async function bulkSoftDeleteCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
SET deleted_at = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, now, patch, userId, ...chunk)
|
||||
.bind(now, now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -148,8 +182,7 @@ export async function bulkRestoreCiphers(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
const chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -157,10 +190,11 @@ export async function bulkRestoreCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
SET deleted_at = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.bind(now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -262,8 +296,7 @@ export async function bulkMoveCiphers(
|
||||
const now = new Date().toISOString();
|
||||
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -271,10 +304,11 @@ export async function bulkMoveCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
SET folder_id = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(normalizedFolderId, now, patch, userId, ...chunk)
|
||||
.bind(normalizedFolderId, now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -293,8 +327,7 @@ export async function bulkArchiveCiphers(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -302,10 +335,11 @@ export async function bulkArchiveCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
SET archived_at = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||
)
|
||||
.bind(now, now, patch, userId, ...chunk)
|
||||
.bind(now, now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -324,8 +358,7 @@ export async function bulkUnarchiveCiphers(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
const chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -333,10 +366,11 @@ export async function bulkUnarchiveCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
SET archived_at = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.bind(now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Cipher, Folder } from '../types';
|
||||
import type { Folder } from '../types';
|
||||
|
||||
function mapFolderRow(row: any): Folder {
|
||||
return {
|
||||
@@ -36,26 +36,18 @@ export async function deleteFolder(db: D1Database, id: string, userId: string):
|
||||
export async function clearFolderFromCiphers(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
folderId: string,
|
||||
saveCipher: (cipher: Cipher) => Promise<void>
|
||||
folderId: string
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const res = await db
|
||||
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
||||
.bind(userId, folderId)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of (res.results || [])) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await saveCipher(cipher);
|
||||
}
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND folder_id = ?`
|
||||
)
|
||||
.bind(now, userId, folderId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function bulkDeleteFolders(
|
||||
@@ -63,34 +55,26 @@ export async function bulkDeleteFolders(
|
||||
userId: string,
|
||||
ids: string[],
|
||||
sqlChunkSize: (fixedBindCount: number) => number,
|
||||
saveCipher: (cipher: Cipher) => Promise<void>,
|
||||
updateRevisionDate: (userId: string) => Promise<string>
|
||||
): Promise<string | null> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
const now = new Date().toISOString();
|
||||
const chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await db
|
||||
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await saveCipher(cipher);
|
||||
}
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND folder_id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, userId, ...chunk)
|
||||
.run();
|
||||
|
||||
await db
|
||||
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||
|
||||
@@ -6,13 +6,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
@@ -28,6 +29,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
@@ -93,11 +95,6 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (identifier, window_start))',
|
||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
||||
const USER_SELECT_COLUMNS =
|
||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||
'totp_secret, totp_recovery_code, created_at, updated_at';
|
||||
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
return {
|
||||
@@ -26,6 +26,7 @@ function mapUserRow(row: any): User {
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
apiKey: row.api_key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -64,11 +65,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
@@ -102,8 +104,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
|
||||
@@ -51,13 +51,13 @@ import {
|
||||
} from './storage-cipher-repo';
|
||||
import {
|
||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
||||
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
||||
deleteAttachment as deleteStoredAttachment,
|
||||
getAttachment as findStoredAttachment,
|
||||
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
||||
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
||||
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
||||
removeAttachmentFromCipher as detachStoredAttachmentFromCipher,
|
||||
saveAttachment as saveStoredAttachment,
|
||||
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
||||
} from './storage-attachment-repo';
|
||||
@@ -108,7 +108,7 @@ import {
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-04-18.1';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-04-28';
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -340,7 +340,6 @@ export class StorageService {
|
||||
userId,
|
||||
ids,
|
||||
this.sqlChunkSize.bind(this),
|
||||
this.saveCipher.bind(this),
|
||||
this.updateRevisionDate.bind(this)
|
||||
);
|
||||
}
|
||||
@@ -348,7 +347,7 @@ export class StorageService {
|
||||
// Clear folder references from all ciphers owned by the user.
|
||||
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
||||
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
||||
await clearStoredFolderFromCiphers(this.db, userId, folderId, this.saveCipher.bind(this));
|
||||
await clearStoredFolderFromCiphers(this.db, userId, folderId);
|
||||
}
|
||||
|
||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||
@@ -373,6 +372,10 @@ export class StorageService {
|
||||
await deleteStoredAttachment(this.db, id);
|
||||
}
|
||||
|
||||
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
||||
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||
}
|
||||
|
||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||
return listStoredAttachmentsByCipher(this.db, cipherId);
|
||||
}
|
||||
@@ -389,10 +392,6 @@ export class StorageService {
|
||||
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
await detachStoredAttachmentFromCipher(cipherId, attachmentId);
|
||||
}
|
||||
|
||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface User {
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
apiKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -449,6 +450,7 @@ export interface FolderResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
revisionDate: string;
|
||||
creationDate: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { JWTPayload } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const hmacKeyCache = new Map<string, Promise<CryptoKey>>();
|
||||
|
||||
// Base64 URL encode
|
||||
function base64UrlEncode(data: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...data));
|
||||
@@ -19,6 +21,23 @@ function base64UrlDecode(str: string): Uint8Array {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getHmacKey(secret: string): Promise<CryptoKey> {
|
||||
const cacheKey = secret;
|
||||
let cached = hmacKeyCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
cached = crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
hmacKeyCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Create JWT
|
||||
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
@@ -40,13 +59,7 @@ export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss'
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
@@ -63,13 +76,7 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
@@ -133,13 +140,7 @@ export async function createFileDownloadToken(
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
@@ -159,13 +160,7 @@ export async function verifyFileDownloadToken(
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
@@ -205,13 +200,7 @@ export async function createAttachmentUploadToken(
|
||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
@@ -229,13 +218,7 @@ export async function verifyAttachmentUploadToken(
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
@@ -285,13 +268,7 @@ export async function createSendFileDownloadToken(
|
||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
@@ -309,13 +286,7 @@ export async function verifySendFileDownloadToken(
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
@@ -361,13 +332,7 @@ export async function createSendFileUploadToken(
|
||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
@@ -385,13 +350,7 @@ export async function verifySendFileUploadToken(
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
@@ -430,13 +389,7 @@ export async function createSendAccessToken(sendId: string, secret: string): Pro
|
||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
return `${data}.${signatureB64}`;
|
||||
@@ -450,13 +403,7 @@ export async function verifySendAccessToken(token: string, secret: string): Prom
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
|
||||
@@ -48,7 +48,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isExtensionOrigin(origin)) {
|
||||
return { allowOrigin: origin, allowCredentials: false };
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: 'var(--bg-accent)',
|
||||
panel: 'var(--panel)',
|
||||
'panel-soft': 'var(--panel-soft)',
|
||||
'panel-muted': 'var(--panel-muted)',
|
||||
line: 'var(--line)',
|
||||
'line-soft': 'var(--line-soft)',
|
||||
ink: 'var(--text)',
|
||||
muted: 'var(--muted)',
|
||||
'muted-strong': 'var(--muted-strong)',
|
||||
brand: 'var(--primary)',
|
||||
'brand-hover': 'var(--primary-hover)',
|
||||
'brand-strong': 'var(--primary-strong)',
|
||||
danger: 'var(--danger)',
|
||||
},
|
||||
boxShadow: {
|
||||
soft: 'var(--shadow-sm)',
|
||||
panel: 'var(--shadow-md)',
|
||||
elevated: 'var(--shadow-lg)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -3,13 +3,92 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cloudflareinsights.com https://*.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cloudflareinsights.com https://*.cloudflareinsights.com; connect-src 'self' https://api.pwnedpasswords.com https://cloudflareinsights.com https://*.cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data:;
|
||||
connect-src 'self';
|
||||
font-src 'self';
|
||||
form-action 'self';
|
||||
base-uri 'self';
|
||||
" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
||||
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<title>NodeWarden</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #eef4ff;
|
||||
color: #0f172a;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.boot-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.boot-card {
|
||||
width: min(420px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
justify-items: center;
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
.boot-logo {
|
||||
width: 74px;
|
||||
height: 58px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.boot-line {
|
||||
width: 72%;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #dbeafe, #bfdbfe, #dbeafe);
|
||||
background-size: 180% 100%;
|
||||
animation: boot-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.boot-line.short {
|
||||
width: 46%;
|
||||
}
|
||||
|
||||
@keyframes boot-shimmer {
|
||||
0% { background-position: 180% 0; }
|
||||
100% { background-position: -180% 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
<div class="boot-screen">
|
||||
<div class="boot-card" aria-label="Loading NodeWarden">
|
||||
<img class="boot-logo" src="/nodewarden-logo.svg" alt="" />
|
||||
<div class="boot-line"></div>
|
||||
<div class="boot-line short"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="760" height="760" viewBox="0 0 760 760" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="760" height="760" fill="#116FF9"/>
|
||||
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
|
||||
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="727" height="580" viewBox="0 0 727 580" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
|
||||
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="862" height="102" viewBox="0 0 8620 1017" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z" fill="black"/>
|
||||
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z" fill="black"/>
|
||||
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z" fill="black"/>
|
||||
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z" fill="black"/>
|
||||
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z" fill="black"/>
|
||||
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z" fill="black"/>
|
||||
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z" fill="black"/>
|
||||
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z" fill="black"/>
|
||||
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z" fill="black"/>
|
||||
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AdminInvite, AdminUser } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -8,6 +9,8 @@ interface AdminPageProps {
|
||||
currentUserId: string;
|
||||
users: AdminUser[];
|
||||
invites: AdminInvite[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
onRefresh: () => void;
|
||||
onCreateInvite: (hours: number) => Promise<void>;
|
||||
onDeleteAllInvites: () => Promise<void>;
|
||||
@@ -48,8 +51,22 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{!!props.error && (
|
||||
<div className="local-error">
|
||||
<span>{props.error}</span>
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_users')}</h3>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -94,6 +111,20 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{props.loading && !props.users.length && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<LoadingState lines={4} compact />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!props.loading && !props.users.length && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<div className="empty empty-comfortable">{t('txt_no_users_found')}</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
@@ -101,7 +132,7 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_invites')}</h3>
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
|
||||
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -160,6 +191,20 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{props.loading && !props.invites.length && (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<LoadingState lines={4} compact />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!props.loading && !props.invites.length && (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<div className="empty empty-comfortable">{t('txt_no_invites_found')}</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="actions">
|
||||
|
||||
@@ -21,19 +21,25 @@ interface AppAuthenticatedShellProps {
|
||||
onLock: () => void;
|
||||
onLogout: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onToggleMobileSidebar: () => void;
|
||||
mainRoutesProps: AppMainRoutesProps;
|
||||
}
|
||||
|
||||
function isAdminProfile(profile: Profile | null): boolean {
|
||||
return String(profile?.role || '').toLowerCase() === 'admin';
|
||||
}
|
||||
|
||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||
const isAdmin = isAdminProfile(props.profile);
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
||||
<span className="brand-name">NodeWarden</span>
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="brand-logo" />
|
||||
<span className="brand-wordmark" role="img" aria-label="NodeWarden" />
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
@@ -51,7 +57,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
className="btn btn-secondary small mobile-sidebar-toggle"
|
||||
aria-label={props.sidebarToggleTitle}
|
||||
title={props.sidebarToggleTitle}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
||||
onClick={props.onToggleMobileSidebar}
|
||||
>
|
||||
<FolderIcon size={16} className="btn-icon" />
|
||||
</button>
|
||||
@@ -82,7 +88,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<SendIcon size={16} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
</Link>
|
||||
{props.profile?.role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
||||
<ShieldUser size={16} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
@@ -96,7 +102,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<Shield size={16} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
{props.profile?.role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
<span>{t('txt_totp_code')}</span>
|
||||
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="check-line" style={{ marginBottom: 0 }}>
|
||||
<label className="check-line check-line-compact">
|
||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
</label>
|
||||
|
||||
@@ -3,15 +3,16 @@ import { useEffect } from 'preact/hooks';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||
@@ -19,7 +20,7 @@ const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
||||
|
||||
function RouteContentFallback() {
|
||||
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
||||
return <LoadingState card lines={5} />;
|
||||
}
|
||||
|
||||
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
||||
@@ -31,22 +32,30 @@ function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
||||
|
||||
export interface AppMainRoutesProps {
|
||||
profile: Profile | null;
|
||||
profileLoading: boolean;
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
mobileSidebarToggleKey: number;
|
||||
importRoute: string;
|
||||
settingsHomeRoute: string;
|
||||
settingsAccountRoute: string;
|
||||
decryptedCiphers: Cipher[];
|
||||
decryptedFolders: VaultFolder[];
|
||||
decryptedSends: Send[];
|
||||
vaultError: string;
|
||||
ciphersLoading: boolean;
|
||||
foldersLoading: boolean;
|
||||
sendsLoading: boolean;
|
||||
users: AdminUser[];
|
||||
invites: AdminInvite[];
|
||||
adminLoading: boolean;
|
||||
adminError: string;
|
||||
totpEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
authorizedDevicesLoading: boolean;
|
||||
authorizedDevicesError: string;
|
||||
onNavigate: (path: string) => void;
|
||||
onLogout: () => void;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -94,6 +103,10 @@ export interface AppMainRoutesProps {
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
@@ -122,6 +135,7 @@ export interface AppMainRoutesProps {
|
||||
|
||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
|
||||
const importPageContent = (
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<ImportPage
|
||||
@@ -163,6 +177,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onBulkDelete={props.onBulkDeleteSends}
|
||||
uploadingSendFileName={props.uploadingSendFileName}
|
||||
sendUploadPercent={props.sendUploadPercent}
|
||||
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -178,6 +193,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
ciphers={props.decryptedCiphers}
|
||||
folders={props.decryptedFolders}
|
||||
loading={props.ciphersLoading || props.foldersLoading}
|
||||
error={props.vaultError}
|
||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateVaultItem}
|
||||
@@ -202,11 +218,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||
/>
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path={props.settingsAccountRoute}>
|
||||
{props.profile && (
|
||||
{props.profile ? (
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
@@ -220,19 +237,27 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
onChangePassword={props.onChangePassword}
|
||||
onSavePasswordHint={props.onSavePasswordHint}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
) : props.profileLoading ? (
|
||||
<LoadingState card lines={5} />
|
||||
) : null}
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
{props.profile && (
|
||||
{props.profile ? (
|
||||
<section className="card mobile-settings-card">
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||
@@ -247,13 +272,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<ArrowUpDown size={18} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
{props.profile.role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<Link href="/admin" className="mobile-settings-link">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{props.profile.role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<Link href="/backup" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
@@ -265,7 +290,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
{t('txt_sign_out')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
) : props.profileLoading ? (
|
||||
<LoadingState card lines={4} />
|
||||
) : null}
|
||||
</Route>
|
||||
<Route path="/security/devices">
|
||||
<div className="stack">
|
||||
@@ -281,6 +308,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SecurityDevicesPage
|
||||
devices={props.authorizedDevices}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
error={props.authorizedDevicesError}
|
||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
@@ -306,6 +334,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
currentUserId={props.profile?.id || ''}
|
||||
users={props.users}
|
||||
invites={props.invites}
|
||||
loading={props.adminLoading}
|
||||
error={props.adminError}
|
||||
onRefresh={props.onRefreshAdmin}
|
||||
onCreateInvite={props.onCreateInvite}
|
||||
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||
@@ -325,7 +355,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<LegacyBackupRedirect onNavigate={props.onNavigate} />
|
||||
</Route>
|
||||
<Route path="/backup">
|
||||
{props.profile?.role === 'admin' ? (
|
||||
{isAdmin ? (
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
|
||||
@@ -19,6 +19,9 @@ interface RegisterValues {
|
||||
|
||||
interface AuthViewsProps {
|
||||
mode: 'login' | 'register' | 'locked';
|
||||
relaxedLoginInput?: boolean;
|
||||
authPlaceholder?: string;
|
||||
unlockPlaceholder?: string;
|
||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||
unlockReady: boolean;
|
||||
unlockPreparing: boolean;
|
||||
@@ -46,6 +49,7 @@ function PasswordField(props: {
|
||||
onInput: (v: string) => void;
|
||||
autoFocus?: boolean;
|
||||
autoComplete?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
@@ -59,6 +63,7 @@ function PasswordField(props: {
|
||||
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
||||
autoFocus={props.autoFocus}
|
||||
autoComplete={props.autoComplete}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
@@ -90,6 +95,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
value={props.unlockPassword}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder={props.unlockPlaceholder}
|
||||
onInput={props.onChangeUnlock}
|
||||
/>
|
||||
<div className="auth-support-row">
|
||||
@@ -217,9 +223,10 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
<span>{t('txt_email')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
type={props.relaxedLoginInput ? 'text' : 'email'}
|
||||
value={props.loginValues.email}
|
||||
autoComplete="username"
|
||||
placeholder={props.authPlaceholder}
|
||||
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
||||
/>
|
||||
</label>
|
||||
@@ -227,6 +234,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
label={t('txt_master_password')}
|
||||
value={props.loginValues.password}
|
||||
autoComplete="current-password"
|
||||
placeholder={props.authPlaceholder}
|
||||
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { TriangleAlert } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -42,6 +42,24 @@ function decrementDialogBodyLock() {
|
||||
body.dataset.dialogCount = String(nextCount);
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
let dialogIdCounter = 0;
|
||||
|
||||
function getFocusableElements(root: HTMLElement): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
|
||||
if (element.hasAttribute('disabled') || element.getAttribute('aria-hidden') === 'true') return false;
|
||||
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
});
|
||||
}
|
||||
|
||||
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -64,7 +82,12 @@ export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | nu
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const [present, setPresent] = useState(props.open);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
|
||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||
const titleId = `${dialogId}-title`;
|
||||
const messageId = `${dialogId}-message`;
|
||||
const canDismiss = !props.cancelDisabled && !closing;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
@@ -83,6 +106,72 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
|
||||
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open || typeof document === 'undefined') return;
|
||||
const activeElement = document.activeElement;
|
||||
restoreFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const card = cardRef.current;
|
||||
if (!card) return;
|
||||
const focusable = getFocusableElements(card);
|
||||
const firstField = focusable.find((element) => (
|
||||
element instanceof HTMLInputElement ||
|
||||
element instanceof HTMLSelectElement ||
|
||||
element instanceof HTMLTextAreaElement
|
||||
));
|
||||
const cancelButton = focusable.find((element) => element.dataset.dialogCancel === 'true');
|
||||
const confirmButton = focusable.find((element) => element.dataset.dialogConfirm === 'true');
|
||||
const target = firstField || (props.danger ? cancelButton : confirmButton) || cancelButton || focusable[0] || card;
|
||||
target.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [props.open, props.danger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open || present || typeof document === 'undefined') return;
|
||||
const target = restoreFocusRef.current;
|
||||
restoreFocusRef.current = null;
|
||||
if (!target || !document.contains(target)) return;
|
||||
target.focus({ preventScroll: true });
|
||||
}, [props.open, present]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const target = restoreFocusRef.current;
|
||||
if (!target || typeof document === 'undefined' || !document.contains(target)) return;
|
||||
target.focus({ preventScroll: true });
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleDialogKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab') return;
|
||||
const card = cardRef.current;
|
||||
if (!card) return;
|
||||
const focusable = getFocusableElements(card);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
card.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === first || activeElement === card || !card.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeElement === last || activeElement === card || !card.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!present || typeof document === 'undefined') return null;
|
||||
return createPortal((
|
||||
<div
|
||||
@@ -93,10 +182,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
}}
|
||||
>
|
||||
<form
|
||||
ref={cardRef}
|
||||
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.title}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={messageId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.confirmDisabled || closing) return;
|
||||
@@ -114,13 +207,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<h3 className="dialog-title">{props.title}</h3>
|
||||
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
||||
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{props.children}
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
@@ -129,6 +223,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
disabled={props.cancelDisabled}
|
||||
data-dialog-cancel="true"
|
||||
onClick={() => {
|
||||
if (props.cancelDisabled) return;
|
||||
props.onCancel();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
interface LoadingStateProps {
|
||||
lines?: number;
|
||||
compact?: boolean;
|
||||
card?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LoadingState(props: LoadingStateProps) {
|
||||
const lines = Math.max(1, props.lines || 4);
|
||||
return (
|
||||
<div className={`${props.card ? 'loading-state-card card' : 'loading-state'}${props.compact ? ' compact' : ''}${props.className ? ` ${props.className}` : ''}`} aria-hidden="true">
|
||||
{Array.from({ length: lines }, (_, index) => (
|
||||
<div key={index} className="loading-state-row">
|
||||
<div className="loading-state-icon shimmer" />
|
||||
<div className="loading-state-text">
|
||||
<div className="loading-state-line shimmer" />
|
||||
<div className="loading-state-line short shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Home } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface NotFoundPageProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
homeHref?: string;
|
||||
}
|
||||
|
||||
export default function NotFoundPage(props: NotFoundPageProps) {
|
||||
const starBoxes = [1, 2, 3, 4];
|
||||
const stars = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
return (
|
||||
<main className="not-found-page">
|
||||
<div className="not-found-space" aria-hidden="true">
|
||||
{starBoxes.map((box) => (
|
||||
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
|
||||
{stars.map((star) => (
|
||||
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="not-found-shell" aria-labelledby="not-found-title">
|
||||
<div className="not-found-brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
||||
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
||||
</div>
|
||||
|
||||
<div className="not-found-astro-stage" aria-hidden="true">
|
||||
<div className="not-found-astronaut">
|
||||
<div className="not-found-astro-head" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-left" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-right" />
|
||||
<div className="not-found-astro-body">
|
||||
<div className="not-found-astro-panel" />
|
||||
</div>
|
||||
<div className="not-found-astro-leg not-found-astro-leg-left" />
|
||||
<div className="not-found-astro-leg not-found-astro-leg-right" />
|
||||
<div className="not-found-astro-pack" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="not-found-copy">
|
||||
<div className="not-found-code">404</div>
|
||||
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
|
||||
<p>{props.message || t('txt_page_not_found_hint')}</p>
|
||||
<a className="btn btn-primary not-found-action" href={props.homeHref || '/'}>
|
||||
<Home size={14} className="btn-icon" />
|
||||
{t('txt_back_to_home')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Download, Eye, Lock } from 'lucide-preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Clipboard, Download, Eye, Lock } from 'lucide-preact';
|
||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toBufferSource } from '@/lib/crypto';
|
||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||
import NotFoundPage from '@/components/NotFoundPage';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { getDemoPublicSend, IS_DEMO_MODE } from '@/lib/demo';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface PublicSendPageProps {
|
||||
@@ -11,38 +14,147 @@ interface PublicSendPageProps {
|
||||
keyPart: string | null;
|
||||
}
|
||||
|
||||
interface PublicSendFileData {
|
||||
id: string;
|
||||
fileName?: string | null;
|
||||
sizeName?: string | null;
|
||||
}
|
||||
|
||||
interface PublicSendData {
|
||||
id: string;
|
||||
type: 0 | 1;
|
||||
decName?: string | null;
|
||||
decText?: string | null;
|
||||
decFileName?: string | null;
|
||||
expirationDate?: string | null;
|
||||
file?: PublicSendFileData | null;
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): Uint8Array | null {
|
||||
try {
|
||||
const raw = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = raw + '='.repeat((4 - (raw.length % 4)) % 4);
|
||||
const decoded = atob(padded);
|
||||
const out = new Uint8Array(decoded.length);
|
||||
for (let i = 0; i < decoded.length; i += 1) out[i] = decoded.charCodeAt(i);
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasUsableSendKey(keyPart: string | null): boolean {
|
||||
if (!keyPart) return false;
|
||||
const bytes = decodeBase64Url(keyPart);
|
||||
return !!bytes && bytes.length >= 16;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function parsePublicSendData(value: unknown): PublicSendData | null {
|
||||
const source = asRecord(value);
|
||||
if (!source) return null;
|
||||
const id = optionalString(source.id);
|
||||
const rawType = Number(source.type);
|
||||
if (!id || (rawType !== 0 && rawType !== 1)) return null;
|
||||
|
||||
const fileSource = asRecord(source.file);
|
||||
const fileId = optionalString(fileSource?.id);
|
||||
const file = fileSource && fileId
|
||||
? {
|
||||
id: fileId,
|
||||
fileName: optionalString(fileSource.fileName),
|
||||
sizeName: optionalString(fileSource.sizeName),
|
||||
}
|
||||
: null;
|
||||
if (rawType === 1 && !file) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
type: rawType,
|
||||
decName: optionalString(source.decName),
|
||||
decText: optionalString(source.decText),
|
||||
decFileName: optionalString(source.decFileName),
|
||||
expirationDate: optionalString(source.expirationDate),
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const initialDemoSend = IS_DEMO_MODE ? getDemoPublicSend(props.accessId) : null;
|
||||
const [loading, setLoading] = useState(!IS_DEMO_MODE);
|
||||
const [password, setPassword] = useState('');
|
||||
const [needPassword, setNeedPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sendData, setSendData] = useState<any>(null);
|
||||
const [notFound, setNotFound] = useState(IS_DEMO_MODE && !initialDemoSend);
|
||||
const [sendData, setSendData] = useState<PublicSendData | null>(initialDemoSend);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
||||
const loadRequestRef = useRef(0);
|
||||
const loadAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
async function loadSend(pass?: string): Promise<void> {
|
||||
loadAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
const requestId = loadRequestRef.current + 1;
|
||||
loadRequestRef.current = requestId;
|
||||
loadAbortRef.current = controller;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setNotFound(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
|
||||
if (IS_DEMO_MODE) {
|
||||
const demoSend = getDemoPublicSend(props.accessId);
|
||||
if (!demoSend) {
|
||||
setNotFound(true);
|
||||
setSendData(null);
|
||||
return;
|
||||
}
|
||||
setSendData(demoSend);
|
||||
setNeedPassword(false);
|
||||
return;
|
||||
}
|
||||
if (!hasUsableSendKey(props.keyPart)) {
|
||||
setNotFound(true);
|
||||
setSendData(null);
|
||||
return;
|
||||
}
|
||||
const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal });
|
||||
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||
if (!props.keyPart) {
|
||||
setError(t('txt_this_link_is_missing_decryption_key'));
|
||||
setSendData(null);
|
||||
return;
|
||||
}
|
||||
const decrypted = await decryptPublicSend(data, props.keyPart);
|
||||
setSendData(decrypted);
|
||||
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||
const parsed = parsePublicSendData(decrypted);
|
||||
if (!parsed) throw new Error(t('txt_send_unavailable'));
|
||||
setSendData(parsed);
|
||||
setNeedPassword(false);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
setNeedPassword(true);
|
||||
setError(t('txt_this_send_is_password_protected'));
|
||||
} else if (err.status === 404) {
|
||||
setNeedPassword(false);
|
||||
setNotFound(true);
|
||||
setError('');
|
||||
} else {
|
||||
setError(err.message || t('txt_failed_to_open_send'));
|
||||
}
|
||||
setSendData(null);
|
||||
} finally {
|
||||
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||
setBusy(false);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -54,6 +166,11 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
setDownloadPercent(null);
|
||||
setError('');
|
||||
try {
|
||||
if (IS_DEMO_MODE) {
|
||||
const bytes = new TextEncoder().encode('NodeWarden demo file Send.\nThis download is generated locally in demo mode.\n');
|
||||
downloadBytesAsFile(bytes, sendData.decFileName || sendData.file?.fileName || 'nodewarden-demo-send.txt', 'application/octet-stream');
|
||||
return;
|
||||
}
|
||||
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(t('txt_download_failed'));
|
||||
@@ -85,12 +202,31 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_DEMO_MODE) {
|
||||
const demoSend = getDemoPublicSend(props.accessId);
|
||||
setSendData(demoSend);
|
||||
setNotFound(!demoSend);
|
||||
setNeedPassword(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadSend();
|
||||
return () => {
|
||||
loadAbortRef.current?.abort();
|
||||
};
|
||||
}, [props.accessId, props.keyPart]);
|
||||
|
||||
if (!loading && notFound) {
|
||||
return <NotFoundPage title={t('txt_page_not_found')} message={t('txt_send_unavailable')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page public-send-page">
|
||||
<StandalonePageFrame title={t('txt_nodewarden_send')}>
|
||||
<StandalonePageFrame
|
||||
title={sendData ? (sendData.decName || t('txt_no_name')) : t('txt_nodewarden_send')}
|
||||
eyebrow={sendData ? t('txt_nodewarden_send') : undefined}
|
||||
>
|
||||
{loading && <p className="muted">{t('txt_loading')}</p>}
|
||||
|
||||
{!loading && needPassword && (
|
||||
@@ -120,13 +256,24 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
|
||||
{!loading && sendData && (
|
||||
<>
|
||||
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
|
||||
{sendData.type === 0 ? (
|
||||
<div className="card" style={{ marginTop: '10px' }}>
|
||||
<div className="card public-send-card">
|
||||
<div className="public-send-card-head">
|
||||
<span>{t('txt_text_send')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small public-send-copy-btn"
|
||||
disabled={!sendData.decText}
|
||||
onClick={() => void copyTextToClipboard(sendData.decText || '')}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="notes">{sendData.decText || ''}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ marginTop: '10px' }}>
|
||||
<div className="card public-send-card">
|
||||
<div className="kv-line">
|
||||
<span>{t('txt_file')}</span>
|
||||
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
|
||||
@@ -142,7 +289,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
|
||||
{!loading && !sendData && !needPassword && !error && (
|
||||
<p className="muted">
|
||||
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')}
|
||||
<Eye size={14} className="inline-status-icon" /> {t('txt_send_unavailable')}
|
||||
</p>
|
||||
)}
|
||||
{!!error && <p className="local-error">{error}</p>}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AuthorizedDevice } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface SecurityDevicesPageProps {
|
||||
devices: AuthorizedDevice[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
onRefresh: () => void;
|
||||
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||
@@ -66,13 +68,13 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
||||
<div className="muted-inline" style={{ marginTop: 4 }}>
|
||||
<h3 className="flush-title">{t('txt_device_management')}</h3>
|
||||
<div className="muted-inline section-note">
|
||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary small" 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_refresh')}
|
||||
</button>
|
||||
@@ -89,7 +91,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
|
||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
||||
{!!props.error && (
|
||||
<div className="local-error">
|
||||
<span>{props.error}</span>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -166,10 +177,17 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{props.loading && props.devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<LoadingState lines={5} compact />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!props.loading && props.devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
|
||||
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { Send, SendDraft } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -14,12 +15,13 @@ interface SendsPageProps {
|
||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||
uploadingSendFileName: string;
|
||||
sendUploadPercent: number | null;
|
||||
mobileSidebarToggleKey: number;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
||||
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||
|
||||
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
||||
if (!iso) return String(fallback);
|
||||
@@ -78,6 +80,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
|
||||
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
||||
@@ -107,12 +110,10 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onToggleSidebar = () => {
|
||||
if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
|
||||
mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
};
|
||||
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
}, []);
|
||||
}, [props.mobileSidebarToggleKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -223,8 +224,17 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAccessUrl(send: Send): string {
|
||||
const rawUrl = send.shareUrl || `/send/${send.accessId}`;
|
||||
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
|
||||
if (rawUrl.startsWith('/#/')) return `${window.location.origin}${rawUrl}`;
|
||||
if (rawUrl.startsWith('#/')) return `${window.location.origin}/${rawUrl}`;
|
||||
if (rawUrl.startsWith('/')) return `${window.location.origin}/#${rawUrl}`;
|
||||
return `${window.location.origin}/#/${rawUrl.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function copyAccessUrl(send: Send): void {
|
||||
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
|
||||
const url = getAccessUrl(send);
|
||||
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
|
||||
}
|
||||
|
||||
@@ -322,11 +332,11 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-panel">
|
||||
{props.loading && !filteredSends.length && <LoadingState lines={6} compact />}
|
||||
{filteredSends.map((send, index) => (
|
||||
<div
|
||||
key={send.id}
|
||||
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
|
||||
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||
className={`list-item stagger-item stagger-delay-${Math.min(index, 10)} ${selectedId === send.id ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check')) return;
|
||||
@@ -376,7 +386,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
||||
{!props.loading && !filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -405,7 +415,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
)}
|
||||
{isEditing && draft && (
|
||||
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
|
||||
<div className="card stagger-item stagger-delay-0">
|
||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||
<div className="field-grid">
|
||||
@@ -505,12 +515,12 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
|
||||
{!isEditing && selectedSend && (
|
||||
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
|
||||
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
|
||||
<div className="card stagger-item stagger-delay-1">
|
||||
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
|
||||
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
||||
</div>
|
||||
|
||||
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
|
||||
<div className="card stagger-item stagger-delay-2">
|
||||
<h4>{t('txt_send_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||
@@ -533,7 +543,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
|
||||
{!!(selectedSend.decNotes || '').trim() && (
|
||||
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
|
||||
<div className="card stagger-item stagger-delay-3">
|
||||
<h4>{t('txt_notes')}</h4>
|
||||
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||
</div>
|
||||
@@ -554,6 +564,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && !selectedSend && props.loading && <LoadingState card lines={4} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,20 +3,34 @@ import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-p
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import type { Profile } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
totpEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
const LOCK_TIMEOUT_OPTIONS = [
|
||||
{ value: 1, labelKey: 'txt_timeout_1_minute' },
|
||||
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
|
||||
{ value: 15, labelKey: 'txt_timeout_15_minutes' },
|
||||
{ value: 30, labelKey: 'txt_timeout_30_minutes' },
|
||||
{ value: 0, labelKey: 'txt_timeout_never' },
|
||||
] as const;
|
||||
|
||||
function randomBase32Secret(length: number): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let out = '';
|
||||
@@ -37,17 +51,39 @@ function buildOtpUri(email: string, secret: string): string {
|
||||
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
function clearLegacyTotpSetupSecrets(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const prefix = 'nodewarden.totp.secret.';
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key?.startsWith(prefix)) keys.push(key);
|
||||
}
|
||||
for (const key of keys) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsPage(props: SettingsPageProps) {
|
||||
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPassword2, setNewPassword2] = useState('');
|
||||
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
|
||||
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
|
||||
const [secret, setSecret] = useState(() => randomBase32Secret(32));
|
||||
const [token, setToken] = useState('');
|
||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
|
||||
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
|
||||
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
|
||||
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
|
||||
|
||||
useEffect(() => {
|
||||
clearLegacyTotpSetupSecrets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.totpEnabled) {
|
||||
@@ -73,19 +109,57 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
async function enableTotp(): Promise<void> {
|
||||
try {
|
||||
await props.onEnableTotp(secret, token);
|
||||
// Secret is now stored on the server; remove plaintext copy from localStorage.
|
||||
localStorage.removeItem(totpSecretStorageKey);
|
||||
setTotpLocked(true);
|
||||
} catch {
|
||||
// Keep inputs editable after a failed attempt.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecoveryCode(): Promise<void> {
|
||||
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
|
||||
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
|
||||
setMasterPasswordPrompt(action);
|
||||
setMasterPasswordPromptValue('');
|
||||
}
|
||||
|
||||
function closeMasterPasswordPrompt(): void {
|
||||
if (masterPasswordPromptSubmitting) return;
|
||||
setMasterPasswordPrompt(null);
|
||||
setMasterPasswordPromptValue('');
|
||||
}
|
||||
|
||||
async function submitMasterPasswordPrompt(): Promise<void> {
|
||||
if (!masterPasswordPrompt || masterPasswordPromptSubmitting) return;
|
||||
const masterPassword = masterPasswordPromptValue;
|
||||
setMasterPasswordPromptSubmitting(true);
|
||||
try {
|
||||
if (masterPasswordPrompt === 'recovery') {
|
||||
const code = await props.onGetRecoveryCode(masterPassword);
|
||||
setRecoveryCode(code);
|
||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||
} else if (masterPasswordPrompt === 'apiKey') {
|
||||
const key = await props.onGetApiKey(masterPassword);
|
||||
setApiKey(key);
|
||||
setApiKeyDialogOpen(true);
|
||||
} else {
|
||||
const key = await props.onRotateApiKey(masterPassword);
|
||||
setApiKey(key);
|
||||
setApiKeyDialogOpen(true);
|
||||
props.onNotify?.('success', t('txt_api_key_rotated'));
|
||||
}
|
||||
setMasterPasswordPrompt(null);
|
||||
setMasterPasswordPromptValue('');
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
|
||||
} finally {
|
||||
setMasterPasswordPromptSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const masterPasswordPromptTitle =
|
||||
masterPasswordPrompt === 'recovery'
|
||||
? t('txt_view_recovery_code')
|
||||
: masterPasswordPrompt === 'rotateApiKey'
|
||||
? t('txt_rotate_api_key')
|
||||
: t('txt_view_api_key');
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
@@ -94,31 +168,66 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
async function changeLocale(next: Locale): Promise<void> {
|
||||
if (next === getLocale()) return;
|
||||
setSelectedLocale(next);
|
||||
await setLocale(next);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>{t('txt_profile')}</h3>
|
||||
<div className="settings-modules-grid">
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_session_timeout')}</h3>
|
||||
<div className="session-timeout-fields">
|
||||
<label className="field">
|
||||
<span>{t('txt_password_hint_optional')}</span>
|
||||
<input
|
||||
<span>{t('txt_timeout_time')}</span>
|
||||
<select
|
||||
className="input"
|
||||
maxLength={120}
|
||||
value={passwordHint}
|
||||
placeholder={t('txt_password_hint_placeholder')}
|
||||
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||
value={String(props.lockTimeoutMinutes)}
|
||||
onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
|
||||
>
|
||||
{t('txt_save_profile')}
|
||||
</button>
|
||||
{LOCK_TIMEOUT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_timeout_action')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={props.sessionTimeoutAction}
|
||||
onInput={(e) => props.onSessionTimeoutActionChange((e.currentTarget as HTMLSelectElement).value === 'logout' ? 'logout' : 'lock')}
|
||||
>
|
||||
<option value="logout">{t('txt_timeout_action_logout')}</option>
|
||||
<option value="lock">{t('txt_timeout_action_lock')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_language')}</h3>
|
||||
<label className="field">
|
||||
<span>{t('txt_display_language')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedLocale}
|
||||
onInput={(e) => void changeLocale((e.currentTarget as HTMLSelectElement).value as Locale)}
|
||||
>
|
||||
{AVAILABLE_LOCALES.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="field-help">{t('txt_language_saved_locally')}</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_change_master_password')}</h3>
|
||||
<label className="field">
|
||||
<span>{t('txt_current_password')}</span>
|
||||
@@ -149,9 +258,29 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="settings-twofactor-grid">
|
||||
<div className="settings-subcard">
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_password_hint_optional')}</h3>
|
||||
<label className="field">
|
||||
<span>{t('txt_password_hint')}</span>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={120}
|
||||
value={passwordHint}
|
||||
placeholder={t('txt_password_hint_placeholder')}
|
||||
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||
>
|
||||
{t('txt_save_profile')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_totp')}</h3>
|
||||
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
||||
<div className="totp-grid">
|
||||
@@ -162,7 +291,33 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>{t('txt_authenticator_key')}</span>
|
||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
<div className="totp-secret-input-wrap">
|
||||
<input className="input totp-secret-input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
<div className="totp-secret-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small totp-secret-icon-btn"
|
||||
disabled={totpLocked}
|
||||
title={t('txt_regenerate')}
|
||||
aria-label={t('txt_regenerate')}
|
||||
onClick={() => setSecret(randomBase32Secret(32))}
|
||||
>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small totp-secret-icon-btn"
|
||||
disabled={totpLocked}
|
||||
title={t('txt_copy_secret')}
|
||||
aria-label={t('txt_copy_secret')}
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
@@ -173,47 +328,28 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_regenerate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={totpLocked}
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy_secret')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_disable_totp')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="settings-subcard">
|
||||
<h3>{t('txt_recovery_code')}</h3>
|
||||
<p className="muted-inline" style={{ marginBottom: 8 }}>
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_recovery_code_and_api_key')}</h3>
|
||||
<div className="sensitive-actions-grid">
|
||||
<div className="sensitive-action">
|
||||
<div>
|
||||
<h4>{t('txt_recovery_code')}</h4>
|
||||
<p className="muted-inline settings-field-note">
|
||||
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
||||
</p>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={recoveryMasterPassword}
|
||||
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('recovery')}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{t('txt_view_recovery_code')}
|
||||
</button>
|
||||
@@ -230,13 +366,109 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
{recoveryCode && (
|
||||
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
|
||||
<div className="recovery-code-card">
|
||||
<div className="recovery-code-value">{recoveryCode}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sensitive-action">
|
||||
<div>
|
||||
<h4>{t('txt_api_key')}</h4>
|
||||
<p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('apiKey')}>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_view_api_key')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setRotateApiKeyConfirmOpen(true)}
|
||||
>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_rotate_api_key')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
open={masterPasswordPrompt !== null}
|
||||
title={masterPasswordPromptTitle}
|
||||
message={t('txt_enter_master_password_to_continue')}
|
||||
confirmText={t('txt_continue')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={masterPasswordPromptSubmitting || !masterPasswordPromptValue.trim()}
|
||||
cancelDisabled={masterPasswordPromptSubmitting}
|
||||
onConfirm={() => void submitMasterPasswordPrompt()}
|
||||
onCancel={closeMasterPasswordPrompt}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={masterPasswordPromptValue}
|
||||
onInput={(e) => setMasterPasswordPromptValue((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={apiKeyDialogOpen}
|
||||
title={t('txt_api_key')}
|
||||
message={t('txt_api_key_dialog_intro')}
|
||||
hideCancel
|
||||
confirmText={t('txt_close')}
|
||||
onConfirm={() => setApiKeyDialogOpen(false)}
|
||||
onCancel={() => setApiKeyDialogOpen(false)}
|
||||
>
|
||||
<div className="api-key-warning-panel">
|
||||
<div className="api-key-warning-title">{t('txt_warning')}</div>
|
||||
<div className="api-key-warning-body">{t('txt_api_key_warning_body')}</div>
|
||||
</div>
|
||||
|
||||
<div className="api-key-credentials-panel">
|
||||
<div className="api-key-credentials-title">
|
||||
<KeyRound size={15} />
|
||||
<span>{t('txt_oauth_client_credentials')}</span>
|
||||
</div>
|
||||
{([
|
||||
[t('txt_client_id'), `user.${props.profile.id}`],
|
||||
[t('txt_client_secret'), apiKey],
|
||||
[t('txt_scope'), 'api'],
|
||||
[t('txt_grant_type'), 'client_credentials'],
|
||||
] as [string, string][]).map(([label, value]) => (
|
||||
<label key={label} className="field">
|
||||
<span>{label}</span>
|
||||
<div className="api-key-credential-row">
|
||||
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
onClick={() => void copyTextToClipboard(value, { successMessage: t('txt_copied') })}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={rotateApiKeyConfirmOpen}
|
||||
title={t('txt_rotate_api_key')}
|
||||
message={t('txt_rotate_api_key_confirm')}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
setRotateApiKeyConfirmOpen(false);
|
||||
openMasterPasswordPrompt('rotateApiKey');
|
||||
}}
|
||||
onCancel={() => setRotateApiKeyConfirmOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { APP_VERSION } from '@shared/app-version';
|
||||
|
||||
interface StandalonePageFrameProps {
|
||||
title: string;
|
||||
eyebrow?: ComponentChildren;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
@@ -10,13 +11,14 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||
return (
|
||||
<div className="standalone-shell">
|
||||
<div className="standalone-brand standalone-brand-outside">
|
||||
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="standalone-brand-logo" />
|
||||
<div>
|
||||
<div className="standalone-brand-title">NodeWarden</div>
|
||||
<span className="standalone-brand-wordmark" role="img" aria-label="NodeWarden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="auth-card">
|
||||
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
{props.children}
|
||||
</div>
|
||||
@@ -26,7 +28,14 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||
<span> | </span>
|
||||
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
|
||||
<span> | </span>
|
||||
<span className="standalone-version">v{APP_VERSION}</span>
|
||||
<a
|
||||
href="https://github.com/shuaiplus/NodeWarden/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="standalone-version"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,9 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||
import { calcTotpNow } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import WebsiteIcon from '@/components/vault/WebsiteIcon';
|
||||
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface TotpCodesPageProps {
|
||||
ciphers: Cipher[];
|
||||
@@ -33,7 +35,14 @@ const TOTP_PERIOD_SECONDS = 30;
|
||||
const TOTP_RING_RADIUS = 14;
|
||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
||||
const failedIconHosts = new Set<string>();
|
||||
const TOTP_REFRESH_BATCH_SIZE = 16;
|
||||
function getTotpTimeState(): { windowId: number; remain: number } {
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
|
||||
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTotp(code: string): string {
|
||||
if (!code) return code;
|
||||
@@ -42,49 +51,8 @@ function formatTotp(code: string): string {
|
||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
function firstCipherUri(cipher: Cipher): string {
|
||||
const uris = cipher.login?.uris || [];
|
||||
for (const uri of uris) {
|
||||
const raw = uri.decUri || uri.uri || '';
|
||||
if (raw.trim()) return raw.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function hostFromUri(uri: string): string {
|
||||
if (!uri.trim()) return '';
|
||||
try {
|
||||
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
||||
return new URL(normalized).hostname || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||
const uri = firstCipherUri(cipher);
|
||||
const host = hostFromUri(uri);
|
||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||
if (host && !errored) {
|
||||
return (
|
||||
<img
|
||||
className="list-icon"
|
||||
src={websiteIconUrl(host)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => {
|
||||
failedIconHosts.add(host);
|
||||
setErrored(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="list-icon-fallback">
|
||||
<Globe size={18} />
|
||||
</span>
|
||||
);
|
||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||
}
|
||||
|
||||
interface SortableTotpRowProps {
|
||||
@@ -164,7 +132,8 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
||||
}
|
||||
|
||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
|
||||
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
||||
const [columnCount, setColumnCount] = useState(1);
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
@@ -195,16 +164,21 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
||||
}
|
||||
|
||||
const nameCollator = useMemo(
|
||||
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
|
||||
[]
|
||||
);
|
||||
|
||||
const baseTotpItems = useMemo(
|
||||
() =>
|
||||
props.ciphers
|
||||
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
const nameA = (a.decName || a.name || '').trim();
|
||||
const nameB = (b.decName || b.name || '').trim();
|
||||
return nameCollator.compare(nameA, nameB);
|
||||
}),
|
||||
[props.ciphers]
|
||||
[props.ciphers, nameCollator]
|
||||
);
|
||||
|
||||
const totpItems = useMemo(() => {
|
||||
@@ -216,11 +190,13 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
if (orderA != null && orderB != null) return orderA - orderB;
|
||||
if (orderA != null) return -1;
|
||||
if (orderB != null) return 1;
|
||||
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
const nameA = (a.decName || a.name || '').trim();
|
||||
const nameB = (b.decName || b.name || '').trim();
|
||||
return nameCollator.compare(nameA, nameB);
|
||||
});
|
||||
}, [baseTotpItems, orderedIds]);
|
||||
}, [baseTotpItems, orderedIds, nameCollator]);
|
||||
|
||||
const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseTotpItems.length) return;
|
||||
@@ -247,26 +223,63 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!totpItems.length) {
|
||||
setTotpMap({});
|
||||
setTotpCodes({});
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
let activeRun = 0;
|
||||
let timer = 0;
|
||||
const tick = async () => {
|
||||
let currentWindowId = -1;
|
||||
|
||||
const refreshCodes = async () => {
|
||||
const runId = ++activeRun;
|
||||
const nextCodes: Record<string, string | null> = {};
|
||||
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
||||
if (stopped || runId !== activeRun) return;
|
||||
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
||||
const entries = await Promise.all(
|
||||
totpItems.map(async (cipher) => {
|
||||
batch.map(async (cipher) => {
|
||||
try {
|
||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||
return [cipher.id, next] as const;
|
||||
return [cipher.id, next?.code || null] as const;
|
||||
} catch {
|
||||
return [cipher.id, null] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
if (!stopped) setTotpMap(Object.fromEntries(entries));
|
||||
for (const [id, code] of entries) nextCodes[id] = code;
|
||||
if (start + TOTP_REFRESH_BATCH_SIZE < totpItems.length) {
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
if (stopped || runId !== activeRun) return;
|
||||
setTotpCodes((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, string | null> = { ...prev };
|
||||
for (const id of Object.keys(next)) {
|
||||
if (id in nextCodes) continue;
|
||||
delete next[id];
|
||||
changed = true;
|
||||
}
|
||||
for (const [id, code] of Object.entries(nextCodes)) {
|
||||
if (next[id] === code) continue;
|
||||
next[id] = code;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
void tick();
|
||||
timer = window.setInterval(() => void tick(), 1000);
|
||||
|
||||
const tick = () => {
|
||||
const next = getTotpTimeState();
|
||||
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
|
||||
if (next.windowId === currentWindowId) return;
|
||||
currentWindowId = next.windowId;
|
||||
void refreshCodes();
|
||||
};
|
||||
|
||||
tick();
|
||||
timer = window.setInterval(tick, 1000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
@@ -315,14 +328,15 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
className="totp-codes-list"
|
||||
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
||||
>
|
||||
{!totpItems.length && props.loading && <LoadingState lines={6} />}
|
||||
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}>
|
||||
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
|
||||
{totpItems.map((cipher) => (
|
||||
<SortableTotpRow
|
||||
key={cipher.id}
|
||||
cipher={cipher}
|
||||
live={totpMap[cipher.id] || null}
|
||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
||||
onCopy={(value) => void copyToClipboard(value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import VaultDialogs from '@/components/vault/VaultDialogs';
|
||||
import VaultDetailView from '@/components/vault/VaultDetailView';
|
||||
import VaultEditor from '@/components/vault/VaultEditor';
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
MOBILE_LAYOUT_QUERY,
|
||||
VAULT_LIST_OVERSCAN,
|
||||
VAULT_LIST_ROW_HEIGHT,
|
||||
FOLDER_SORT_STORAGE_KEY,
|
||||
VAULT_SORT_STORAGE_KEY,
|
||||
cipherTypeKey,
|
||||
cipherTypeLabel,
|
||||
@@ -34,6 +36,7 @@ interface VaultPageProps {
|
||||
ciphers: Cipher[];
|
||||
folders: Folder[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
emailForReprompt: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||
@@ -58,6 +61,7 @@ interface VaultPageProps {
|
||||
attachmentDownloadPercent: number | null;
|
||||
uploadingAttachmentName: string;
|
||||
attachmentUploadPercent: number | null;
|
||||
mobileSidebarToggleKey: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +75,8 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [searchComposing, setSearchComposing] = useState(false);
|
||||
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
|
||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
||||
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||
@@ -110,10 +116,14 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const sortMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const folderSortMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const listPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
|
||||
|
||||
const sshSeedTicketRef = useRef(0);
|
||||
const sshFingerprintTicketRef = useRef(0);
|
||||
const listScrollBucketRef = useRef(0);
|
||||
const [listScrollTop, setListScrollTop] = useState(0);
|
||||
const [listViewportHeight, setListViewportHeight] = useState(0);
|
||||
|
||||
@@ -131,12 +141,10 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onToggleSidebar = () => {
|
||||
if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
|
||||
mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
};
|
||||
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
}, []);
|
||||
}, [props.mobileSidebarToggleKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const onQuickAdd = () => {
|
||||
@@ -165,6 +173,25 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}
|
||||
}, [sortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
|
||||
if (saved === 'edited' || saved === 'created' || saved === 'name') {
|
||||
setFolderSortMode(saved);
|
||||
}
|
||||
} catch {
|
||||
// ignore storage read failures
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(FOLDER_SORT_STORAGE_KEY, folderSortMode);
|
||||
} catch {
|
||||
// ignore storage write failures
|
||||
}
|
||||
}, [folderSortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = listPanelRef.current;
|
||||
if (!node) return;
|
||||
@@ -213,6 +240,25 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
};
|
||||
}, [sortMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: Event) => {
|
||||
if (!folderSortMenuOpen) return;
|
||||
const target = event.target as Node | null;
|
||||
if (folderSortMenuRef.current && target && !folderSortMenuRef.current.contains(target)) {
|
||||
setFolderSortMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setFolderSortMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [folderSortMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setRepromptApprovedCipherId(null);
|
||||
setRepromptPassword('');
|
||||
@@ -243,29 +289,75 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
void recalculateSshFingerprint(draft.sshPublicKey);
|
||||
}, [isEditing, draft?.id, draft?.type]);
|
||||
|
||||
const duplicateSignatureCounts = useMemo(() => {
|
||||
const cipherMetaById = useMemo(() => {
|
||||
const meta = new Map<string, {
|
||||
name: string;
|
||||
searchText: string;
|
||||
firstUri: string;
|
||||
typeKey: string;
|
||||
sortTime: number;
|
||||
creationTime: number;
|
||||
}>();
|
||||
for (const cipher of props.ciphers) {
|
||||
const name = String(cipher.decName || cipher.name || '');
|
||||
const username = String(cipher.login?.decUsername || '');
|
||||
const uri = firstCipherUri(cipher);
|
||||
meta.set(cipher.id, {
|
||||
name,
|
||||
searchText: `${name}\n${username}\n${uri}`.toLowerCase(),
|
||||
firstUri: uri,
|
||||
typeKey: cipherTypeKey(Number(cipher.type || 1)),
|
||||
sortTime: sortTimeValue(cipher),
|
||||
creationTime: creationTimeValue(cipher),
|
||||
});
|
||||
}
|
||||
return meta;
|
||||
}, [props.ciphers]);
|
||||
|
||||
const cipherById = useMemo(() => {
|
||||
const map = new Map<string, Cipher>();
|
||||
for (const cipher of props.ciphers) map.set(cipher.id, cipher);
|
||||
return map;
|
||||
}, [props.ciphers]);
|
||||
|
||||
const folderById = useMemo(() => {
|
||||
const map = new Map<string, Folder>();
|
||||
for (const folder of props.folders) map.set(folder.id, folder);
|
||||
return map;
|
||||
}, [props.folders]);
|
||||
|
||||
const nameCollator = useMemo(
|
||||
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
|
||||
[]
|
||||
);
|
||||
|
||||
const duplicateSignatureInfo = useMemo(() => {
|
||||
if (sidebarFilter.kind !== 'duplicates') return null;
|
||||
const byId = new Map<string, string>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const cipher of props.ciphers) {
|
||||
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||
const signature = buildCipherDuplicateSignature(cipher);
|
||||
byId.set(cipher.id, signature);
|
||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [props.ciphers]);
|
||||
return { byId, counts };
|
||||
}, [props.ciphers, sidebarFilter.kind]);
|
||||
|
||||
const filteredCiphers = useMemo(() => {
|
||||
const next = props.ciphers.filter((cipher) => {
|
||||
const meta = cipherMetaById.get(cipher.id);
|
||||
if (sidebarFilter.kind === 'trash') {
|
||||
if (!isCipherVisibleInTrash(cipher)) return false;
|
||||
} else if (sidebarFilter.kind === 'archive') {
|
||||
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||
} else {
|
||||
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
|
||||
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
|
||||
return false;
|
||||
}
|
||||
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
||||
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
|
||||
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
||||
if (sidebarFilter.kind === 'folder') {
|
||||
if (sidebarFilter.folderId === null) {
|
||||
if (cipher.folderId) return false;
|
||||
@@ -275,24 +367,20 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}
|
||||
}
|
||||
if (!searchQuery) return true;
|
||||
const name = (cipher.decName || '').toLowerCase();
|
||||
const username = (cipher.login?.decUsername || '').toLowerCase();
|
||||
const uri = firstCipherUri(cipher).toLowerCase();
|
||||
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
|
||||
return !!meta?.searchText.includes(searchQuery);
|
||||
});
|
||||
|
||||
next.sort((a, b) => {
|
||||
const metaA = cipherMetaById.get(a.id);
|
||||
const metaB = cipherMetaById.get(b.id);
|
||||
if (sortMode === 'edited') {
|
||||
const diff = sortTimeValue(b) - sortTimeValue(a);
|
||||
const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
|
||||
if (diff !== 0) return diff;
|
||||
} else if (sortMode === 'created') {
|
||||
const diff = creationTimeValue(b) - creationTimeValue(a);
|
||||
const diff = (metaB?.creationTime || 0) - (metaA?.creationTime || 0);
|
||||
if (diff !== 0) return diff;
|
||||
} else {
|
||||
const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
});
|
||||
const nameDiff = nameCollator.compare(metaA?.name || '', metaB?.name || '');
|
||||
if (nameDiff !== 0) return nameDiff;
|
||||
}
|
||||
|
||||
@@ -300,7 +388,13 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
});
|
||||
|
||||
return next;
|
||||
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]);
|
||||
}, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, nameCollator]);
|
||||
|
||||
const filteredCipherIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const cipher of filteredCiphers) ids.add(cipher.id);
|
||||
return ids;
|
||||
}, [filteredCiphers]);
|
||||
|
||||
const sidebarFilterKey = useMemo(() => {
|
||||
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
||||
@@ -310,6 +404,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
setListScrollTop(0);
|
||||
listScrollBucketRef.current = 0;
|
||||
listPanelRef.current?.scrollTo({ top: 0 });
|
||||
}, [searchQuery, sortMode, sidebarFilterKey]);
|
||||
|
||||
@@ -325,15 +420,12 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
if (selectedCipherId) setSelectedCipherId('');
|
||||
return;
|
||||
}
|
||||
if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) {
|
||||
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
|
||||
setSelectedCipherId(filteredCiphers[0].id);
|
||||
}
|
||||
}, [filteredCiphers, selectedCipherId, isCreating]);
|
||||
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
|
||||
|
||||
const selectedCipher = useMemo(
|
||||
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
|
||||
[props.ciphers, selectedCipherId]
|
||||
);
|
||||
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
|
||||
const virtualRange = useMemo(() => {
|
||||
if (!filteredCiphers.length) {
|
||||
return { start: 0, end: 0, padTop: 0, padBottom: 0 };
|
||||
@@ -397,20 +489,27 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
);
|
||||
const totalCipherCount = filteredCiphers.length;
|
||||
|
||||
function folderName(id: string | null | undefined): string {
|
||||
const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (!id) return t('txt_no_folder');
|
||||
const folder = props.folders.find((x) => x.id === id);
|
||||
const folder = folderById.get(id);
|
||||
return folder?.decName || folder?.name || id;
|
||||
}
|
||||
}, [folderById]);
|
||||
|
||||
function listSubtitle(cipher: Cipher): string {
|
||||
const listSubtitle = useCallback((cipher: Cipher): string => {
|
||||
if (Number(cipher.type || 1) === 1) {
|
||||
return cipher.login?.decUsername || firstCipherUri(cipher) || '';
|
||||
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
|
||||
}
|
||||
return cipherTypeLabel(Number(cipher.type || 1));
|
||||
}
|
||||
}, [cipherMetaById]);
|
||||
|
||||
function startCreate(type: number): void {
|
||||
const handleListScroll = useCallback((top: number): void => {
|
||||
const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT);
|
||||
if (bucket === listScrollBucketRef.current) return;
|
||||
listScrollBucketRef.current = bucket;
|
||||
setListScrollTop(top);
|
||||
}, []);
|
||||
|
||||
const startCreate = useCallback((type: number): void => {
|
||||
setDraft(createEmptyDraft(type));
|
||||
setIsCreating(true);
|
||||
setIsEditing(true);
|
||||
@@ -423,9 +522,9 @@ function folderName(id: string | null | undefined): string {
|
||||
if (isMobileLayout) setMobilePanel('edit');
|
||||
setMobileSidebarOpen(false);
|
||||
if (type === 5) void seedSshDefaults();
|
||||
}
|
||||
}, [isMobileLayout]);
|
||||
|
||||
function startEdit(): void {
|
||||
const startEdit = useCallback((): void => {
|
||||
if (!selectedCipher) return;
|
||||
setDraft(draftFromCipher(selectedCipher));
|
||||
setIsCreating(false);
|
||||
@@ -436,9 +535,9 @@ function folderName(id: string | null | undefined): string {
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel('edit');
|
||||
setMobileSidebarOpen(false);
|
||||
}
|
||||
}, [selectedCipher, isMobileLayout]);
|
||||
|
||||
function cancelEdit(): void {
|
||||
const cancelEdit = useCallback((): void => {
|
||||
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
|
||||
setDraft(null);
|
||||
setIsEditing(false);
|
||||
@@ -448,11 +547,11 @@ function folderName(id: string | null | undefined): string {
|
||||
setRemovedAttachmentIds({});
|
||||
setPendingDeletePasskeyIndex(null);
|
||||
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||
}
|
||||
}, [isMobileLayout, isCreating, selectedCipher]);
|
||||
|
||||
function updateDraft(patch: Partial<VaultDraft>): void {
|
||||
const updateDraft = useCallback((patch: Partial<VaultDraft>): void => {
|
||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
}
|
||||
}, []);
|
||||
|
||||
function confirmDeleteLoginPasskey(): void {
|
||||
if (pendingDeletePasskeyIndex == null) return;
|
||||
@@ -817,16 +916,88 @@ function folderName(id: string | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearSearch = useCallback(() => setSearchInput(''), []);
|
||||
const handleSearchCompositionStart = useCallback(() => setSearchComposing(true), []);
|
||||
const handleSearchCompositionEnd = useCallback((value: string) => {
|
||||
setSearchComposing(false);
|
||||
setSearchInput(value);
|
||||
}, []);
|
||||
const handleToggleSortMenu = useCallback(() => setSortMenuOpen((open) => !open), []);
|
||||
const handleSelectSortMode = useCallback((value: VaultSortMode) => {
|
||||
setSortMode(value);
|
||||
setSortMenuOpen(false);
|
||||
}, []);
|
||||
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
||||
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
||||
const handleSelectDuplicates = useCallback(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const cipher of filteredCiphers) {
|
||||
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
|
||||
if (seen.has(signature)) {
|
||||
map[cipher.id] = true;
|
||||
continue;
|
||||
}
|
||||
seen.add(signature);
|
||||
}
|
||||
setSelectedMap(map);
|
||||
}, [filteredCiphers, duplicateSignatureInfo]);
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||
setSelectedMap(map);
|
||||
}, [filteredCiphers]);
|
||||
const handleToggleCreateMenu = useCallback(() => setCreateMenuOpen((open) => !open), []);
|
||||
const handleBulkRestore = useCallback(() => { void confirmBulkRestore(); }, [selectedMap, props.onBulkRestore]);
|
||||
const handleBulkArchive = useCallback(() => setBulkArchiveOpen(true), []);
|
||||
const handleBulkUnarchive = useCallback(() => { void confirmBulkUnarchive(); }, [selectedMap, props.onBulkUnarchive]);
|
||||
const handleOpenMove = useCallback(() => {
|
||||
setMoveFolderId('__none__');
|
||||
setMoveOpen(true);
|
||||
}, []);
|
||||
const handleClearSelection = useCallback(() => setSelectedMap({}), []);
|
||||
const handleToggleSelected = useCallback((cipherId: string, checked: boolean) =>
|
||||
setSelectedMap((prev) => {
|
||||
if (checked) return { ...prev, [cipherId]: true };
|
||||
if (!prev[cipherId]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[cipherId];
|
||||
return next;
|
||||
})
|
||||
, []);
|
||||
const handleSelectCipher = useCallback((cipherId: string) => {
|
||||
if (isEditing || isCreating) {
|
||||
cancelEdit();
|
||||
}
|
||||
setSelectedCipherId(cipherId);
|
||||
setRepromptApprovedCipherId(null);
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
setMobileSidebarOpen(false);
|
||||
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
|
||||
const handleCloseMobileSidebar = useCallback(() => setMobileSidebarOpen(false), []);
|
||||
const handleOpenDeleteAllFolders = useCallback(() => setDeleteAllFoldersOpen(true), []);
|
||||
const handleOpenCreateFolder = useCallback(() => setCreateFolderOpen(true), []);
|
||||
const handleOpenRenameFolder = useCallback((folder: Folder) => {
|
||||
setPendingRenameFolder(folder);
|
||||
setRenameFolderName(folder.decName || folder.name || '');
|
||||
}, []);
|
||||
const handleToggleFolderSortMenu = useCallback(() => setFolderSortMenuOpen((open) => !open), []);
|
||||
const handleSelectFolderSortMode = useCallback((value: VaultSortMode) => {
|
||||
setFolderSortMode(value);
|
||||
setFolderSortMenuOpen(false);
|
||||
}, []);
|
||||
const handleMobileSidebarMaskClick = useCallback(() => {
|
||||
if (!mobileSidebarOpen) return;
|
||||
setMobileSidebarOpen(false);
|
||||
}, [mobileSidebarOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||
{isMobileLayout && (
|
||||
<div
|
||||
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
if (!mobileSidebarOpen) return;
|
||||
setMobileSidebarOpen(false);
|
||||
}}
|
||||
onClick={handleMobileSidebarMaskClick}
|
||||
/>
|
||||
)}
|
||||
<VaultSidebar
|
||||
@@ -835,20 +1006,23 @@ function folderName(id: string | null | undefined): string {
|
||||
busy={busy}
|
||||
isMobileLayout={isMobileLayout}
|
||||
mobileSidebarOpen={mobileSidebarOpen}
|
||||
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
|
||||
folderSortMode={folderSortMode}
|
||||
folderSortMenuOpen={folderSortMenuOpen}
|
||||
folderSortMenuRef={folderSortMenuRef}
|
||||
onCloseMobileSidebar={handleCloseMobileSidebar}
|
||||
onChangeFilter={setSidebarFilter}
|
||||
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
|
||||
onOpenCreateFolder={() => setCreateFolderOpen(true)}
|
||||
onOpenRenameFolder={(folder) => {
|
||||
setPendingRenameFolder(folder);
|
||||
setRenameFolderName(folder.decName || folder.name || '');
|
||||
}}
|
||||
onOpenDeleteAllFolders={handleOpenDeleteAllFolders}
|
||||
onOpenCreateFolder={handleOpenCreateFolder}
|
||||
onOpenRenameFolder={handleOpenRenameFolder}
|
||||
onOpenDeleteFolder={setPendingDeleteFolder}
|
||||
onToggleFolderSortMenu={handleToggleFolderSortMenu}
|
||||
onSelectFolderSortMode={handleSelectFolderSortMode}
|
||||
/>
|
||||
|
||||
<VaultListPanel
|
||||
busy={busy}
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
searchInput={searchInput}
|
||||
sortMode={sortMode}
|
||||
sortMenuOpen={sortMenuOpen}
|
||||
@@ -860,68 +1034,32 @@ function folderName(id: string | null | undefined): string {
|
||||
selectedCipherId={selectedCipherId}
|
||||
selectedMap={selectedMap}
|
||||
sidebarFilter={sidebarFilter}
|
||||
isMobileLayout={isMobileLayout}
|
||||
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
|
||||
createMenuOpen={createMenuOpen}
|
||||
createMenuRef={createMenuRef}
|
||||
sortMenuRef={sortMenuRef}
|
||||
listPanelRef={listPanelRef}
|
||||
onSearchInput={setSearchInput}
|
||||
onClearSearch={() => setSearchInput('')}
|
||||
onSearchCompositionStart={() => setSearchComposing(true)}
|
||||
onSearchCompositionEnd={(value) => {
|
||||
setSearchComposing(false);
|
||||
setSearchInput(value);
|
||||
}}
|
||||
onToggleSortMenu={() => setSortMenuOpen((open) => !open)}
|
||||
onSelectSortMode={(value) => {
|
||||
setSortMode(value);
|
||||
setSortMenuOpen(false);
|
||||
}}
|
||||
onSyncVault={() => void syncVault()}
|
||||
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
|
||||
onSelectDuplicates={() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const cipher of filteredCiphers) {
|
||||
const signature = buildCipherDuplicateSignature(cipher);
|
||||
if (seen.has(signature)) {
|
||||
map[cipher.id] = true;
|
||||
continue;
|
||||
}
|
||||
seen.add(signature);
|
||||
}
|
||||
setSelectedMap(map);
|
||||
}}
|
||||
onSelectAll={() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||
setSelectedMap(map);
|
||||
}}
|
||||
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
|
||||
onClearSearch={handleClearSearch}
|
||||
onSearchCompositionStart={handleSearchCompositionStart}
|
||||
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||
onToggleSortMenu={handleToggleSortMenu}
|
||||
onSelectSortMode={handleSelectSortMode}
|
||||
onSyncVault={handleSyncVault}
|
||||
onOpenBulkDelete={handleOpenBulkDelete}
|
||||
onSelectDuplicates={handleSelectDuplicates}
|
||||
onSelectAll={handleSelectAll}
|
||||
onToggleCreateMenu={handleToggleCreateMenu}
|
||||
onStartCreate={startCreate}
|
||||
onBulkRestore={() => void confirmBulkRestore()}
|
||||
onBulkArchive={() => setBulkArchiveOpen(true)}
|
||||
onBulkUnarchive={() => void confirmBulkUnarchive()}
|
||||
onOpenMove={() => {
|
||||
setMoveFolderId('__none__');
|
||||
setMoveOpen(true);
|
||||
}}
|
||||
onClearSelection={() => setSelectedMap({})}
|
||||
onScroll={setListScrollTop}
|
||||
onToggleSelected={(cipherId, checked) =>
|
||||
setSelectedMap((prev) => ({
|
||||
...prev,
|
||||
[cipherId]: checked,
|
||||
}))
|
||||
}
|
||||
onSelectCipher={(cipherId) => {
|
||||
if (isEditing || isCreating) {
|
||||
cancelEdit();
|
||||
}
|
||||
setSelectedCipherId(cipherId);
|
||||
setRepromptApprovedCipherId(null);
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
setMobileSidebarOpen(false);
|
||||
}}
|
||||
onBulkRestore={handleBulkRestore}
|
||||
onBulkArchive={handleBulkArchive}
|
||||
onBulkUnarchive={handleBulkUnarchive}
|
||||
onOpenMove={handleOpenMove}
|
||||
onClearSelection={handleClearSelection}
|
||||
onScroll={handleListScroll}
|
||||
onToggleSelected={handleToggleSelected}
|
||||
onSelectCipher={handleSelectCipher}
|
||||
listSubtitle={listSubtitle}
|
||||
/>
|
||||
|
||||
@@ -1004,7 +1142,20 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
|
||||
{!isEditing && !selectedCipher && (
|
||||
props.loading
|
||||
? <LoadingState card lines={5} />
|
||||
: props.error
|
||||
? (
|
||||
<div className="empty card vault-error-state">
|
||||
<strong>{props.error}</strong>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={handleSyncVault}>
|
||||
{t('txt_retry_sync')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: <div className="empty card">{t('txt_select_an_item')}</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
||||
import type {
|
||||
BackupDestinationRecord,
|
||||
E3BackupDestination,
|
||||
RemoteBackupBrowserResponse,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '@/lib/api/backup';
|
||||
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||
@@ -399,97 +399,97 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.selectedDestination.type === 'e3' ? (
|
||||
{props.selectedDestination.type === 's3' ? (
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_backup_e3_endpoint')}</span>
|
||||
<span>{t('txt_backup_s3_endpoint')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).endpoint}
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).endpoint}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="https://s3.example.com"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
...(destination.destination as S3BackupDestination),
|
||||
endpoint: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_e3_bucket')}</span>
|
||||
<span>{t('txt_backup_s3_bucket')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).bucket}
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).bucket}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
...(destination.destination as S3BackupDestination),
|
||||
bucket: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_e3_region')}</span>
|
||||
<span>{t('txt_backup_s3_region')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).region}
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).region}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="auto"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
...(destination.destination as S3BackupDestination),
|
||||
region: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_e3_access_key')}</span>
|
||||
<span>{t('txt_backup_s3_access_key')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId}
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).accessKeyId}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
...(destination.destination as S3BackupDestination),
|
||||
accessKeyId: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_e3_secret_key')}</span>
|
||||
<span>{t('txt_backup_s3_secret_key')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey}
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).secretAccessKey}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
...(destination.destination as S3BackupDestination),
|
||||
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_backup_e3_path')}</span>
|
||||
<span>{t('txt_backup_s3_path')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).rootPath}
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="nodewarden/backups"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
...(destination.destination as S3BackupDestination),
|
||||
rootPath: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
|
||||
@@ -60,8 +60,8 @@ export function BackupDestinationSidebar(props: BackupDestinationSidebarProps) {
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
|
||||
{t('txt_backup_protocol_webdav')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('e3')}>
|
||||
{t('txt_backup_protocol_e3')}
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('s3')}>
|
||||
{t('txt_backup_protocol_s3')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
TOTP_PERIOD_SECONDS,
|
||||
TOTP_RING_CIRCUMFERENCE,
|
||||
VaultListIcon,
|
||||
copyToClipboard,
|
||||
formatAttachmentSize,
|
||||
formatHistoryTime,
|
||||
@@ -105,7 +106,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<div className="card">
|
||||
<h4>{t('txt_master_password_reprompt_2')}</h4>
|
||||
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
|
||||
<div className="actions" style={{ marginTop: '10px' }}>
|
||||
<div className="actions detail-unlock-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
|
||||
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
|
||||
</button>
|
||||
@@ -115,9 +116,19 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="detail-title-row">
|
||||
<span className="detail-title-icon" aria-hidden="true">
|
||||
<VaultListIcon cipher={props.selectedCipher} />
|
||||
</span>
|
||||
<div className="detail-title-main">
|
||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
|
||||
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
|
||||
<div className="detail-folder-line">
|
||||
<Folder size={13} aria-hidden="true" />
|
||||
<span>{props.folderName(props.selectedCipher.folderId)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
|
||||
</div>
|
||||
|
||||
{props.selectedCipher.login && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { CustomFieldType, Folder } from '@/lib/types';
|
||||
import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
||||
import { getFieldTypeOptions, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface VaultDialogsProps {
|
||||
@@ -61,6 +61,7 @@ interface VaultDialogsProps {
|
||||
}
|
||||
|
||||
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
const fieldTypeOptions = getFieldTypeOptions();
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
@@ -75,7 +76,7 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
<label className="field">
|
||||
<span>{t('txt_field_type')}</span>
|
||||
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
|
||||
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||
{fieldTypeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { JSX, RefObject } from 'preact';
|
||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -21,13 +23,13 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CREATE_TYPE_OPTIONS,
|
||||
cipherTypeLabel,
|
||||
createEmptyLoginUri,
|
||||
formatAttachmentSize,
|
||||
formatHistoryTime,
|
||||
getCreateTypeOptions,
|
||||
getWebsiteMatchOptions,
|
||||
toBooleanFieldValue,
|
||||
WEBSITE_MATCH_OPTIONS,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultEditorProps {
|
||||
@@ -77,6 +79,7 @@ interface SortableWebsiteRowProps {
|
||||
}
|
||||
|
||||
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
const websiteMatchOptions = getWebsiteMatchOptions();
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.id,
|
||||
});
|
||||
@@ -117,7 +120,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
|
||||
}}
|
||||
>
|
||||
{WEBSITE_MATCH_OPTIONS.map((option) => (
|
||||
{websiteMatchOptions.map((option) => (
|
||||
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
@@ -134,9 +137,18 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
}
|
||||
|
||||
export default function VaultEditor(props: VaultEditorProps) {
|
||||
const createTypeOptions = getCreateTypeOptions();
|
||||
const uriIdSeedRef = useRef(0);
|
||||
const totpQrVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const totpQrFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const totpQrStreamRef = useRef<MediaStream | null>(null);
|
||||
const totpQrFrameRef = useRef<number | null>(null);
|
||||
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
|
||||
const [activeUriId, setActiveUriId] = useState<string | null>(null);
|
||||
const [totpQrOpen, setTotpQrOpen] = useState(false);
|
||||
const [totpQrStatus, setTotpQrStatus] = useState('');
|
||||
const [totpQrBusy, setTotpQrBusy] = useState(false);
|
||||
useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false));
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
@@ -153,6 +165,63 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
|
||||
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
|
||||
|
||||
const stopTotpQrScanner = () => {
|
||||
if (totpQrFrameRef.current != null) {
|
||||
window.cancelAnimationFrame(totpQrFrameRef.current);
|
||||
totpQrFrameRef.current = null;
|
||||
}
|
||||
if (totpQrStreamRef.current) {
|
||||
for (const track of totpQrStreamRef.current.getTracks()) track.stop();
|
||||
totpQrStreamRef.current = null;
|
||||
}
|
||||
if (totpQrVideoRef.current) {
|
||||
totpQrVideoRef.current.srcObject = null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyTotpQrValue = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
props.onUpdateDraft({ loginTotp: trimmed });
|
||||
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
||||
setTotpQrOpen(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
const createTotpQrDetector = (): BarcodeDetector | null => {
|
||||
if (typeof window === 'undefined' || !window.BarcodeDetector) return null;
|
||||
return new window.BarcodeDetector({ formats: ['qr_code'] });
|
||||
};
|
||||
|
||||
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => {
|
||||
const detector = createTotpQrDetector();
|
||||
if (!detector) {
|
||||
setTotpQrStatus(t('txt_totp_qr_unsupported'));
|
||||
return false;
|
||||
}
|
||||
const results = await detector.detect(source);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
if (!value) return false;
|
||||
return applyTotpQrValue(value);
|
||||
};
|
||||
|
||||
const handleTotpQrFile = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
setTotpQrBusy(true);
|
||||
setTotpQrStatus(t('txt_totp_qr_scanning'));
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
const found = await decodeTotpQrImage(bitmap);
|
||||
if (!found) setTotpQrStatus(t('txt_totp_qr_not_found'));
|
||||
} catch {
|
||||
setTotpQrStatus(t('txt_totp_qr_scan_failed'));
|
||||
} finally {
|
||||
bitmap?.close();
|
||||
setTotpQrBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUriItemIds((prev) => {
|
||||
if (prev.length === props.draft.loginUris.length) return prev;
|
||||
@@ -168,6 +237,77 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
setActiveUriId(null);
|
||||
}, [props.draft.id, props.isCreating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!totpQrOpen) {
|
||||
stopTotpQrScanner();
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
const detector = createTotpQrDetector();
|
||||
if (!detector) {
|
||||
setTotpQrStatus(t('txt_totp_qr_unsupported'));
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTotpQrScanner();
|
||||
};
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTotpQrScanner();
|
||||
};
|
||||
}
|
||||
|
||||
const scan = async () => {
|
||||
if (stopped) return;
|
||||
const video = totpQrVideoRef.current;
|
||||
if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
totpQrFrameRef.current = window.requestAnimationFrame(scan);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await detector.detect(video);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
if (value && applyTotpQrValue(value)) return;
|
||||
} catch {
|
||||
// Keep the camera active; transient frame decode failures are common.
|
||||
}
|
||||
totpQrFrameRef.current = window.requestAnimationFrame(scan);
|
||||
};
|
||||
|
||||
setTotpQrBusy(true);
|
||||
setTotpQrStatus(t('txt_totp_qr_starting_camera'));
|
||||
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
|
||||
.then((stream) => {
|
||||
if (stopped) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
return;
|
||||
}
|
||||
totpQrStreamRef.current = stream;
|
||||
const video = totpQrVideoRef.current;
|
||||
if (!video) return;
|
||||
video.srcObject = stream;
|
||||
setTotpQrStatus(t('txt_totp_qr_point_camera'));
|
||||
void video.play().then(() => {
|
||||
setTotpQrBusy(false);
|
||||
totpQrFrameRef.current = window.requestAnimationFrame(scan);
|
||||
}).catch(() => {
|
||||
setTotpQrBusy(false);
|
||||
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setTotpQrBusy(false);
|
||||
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
|
||||
});
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTotpQrScanner();
|
||||
};
|
||||
}, [totpQrOpen]);
|
||||
|
||||
const formatDownloadLabel = (attachmentId: string) => {
|
||||
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -232,7 +372,7 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
if (nextType === 5) props.onSeedSshDefaults();
|
||||
}}
|
||||
>
|
||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||
{createTypeOptions.map((option) => (
|
||||
<option key={option.type} value={option.type}>
|
||||
{option.label}
|
||||
</option>
|
||||
@@ -272,7 +412,22 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_secret')}</span>
|
||||
<div className="input-action-wrap">
|
||||
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
|
||||
<button
|
||||
type="button"
|
||||
className="input-icon-btn"
|
||||
title={t('txt_scan_totp_qr')}
|
||||
aria-label={t('txt_scan_totp_qr')}
|
||||
disabled={props.busy}
|
||||
onClick={() => {
|
||||
setTotpQrStatus('');
|
||||
setTotpQrOpen(true);
|
||||
}}
|
||||
>
|
||||
<QrCode size={18} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div className="section-head">
|
||||
<h4>{t('txt_websites')}</h4>
|
||||
@@ -299,7 +454,7 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
</DndContext>
|
||||
{props.draft.loginFido2Credentials.length > 0 && (
|
||||
<>
|
||||
<div className="section-head" style={{ marginTop: '18px' }}>
|
||||
<div className="section-head passkeys-section-head">
|
||||
<h4>{t('txt_passkeys')}</h4>
|
||||
</div>
|
||||
<div className="attachment-list">
|
||||
@@ -569,6 +724,52 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
)}
|
||||
</div>
|
||||
{props.localError && <div className="local-error">{props.localError}</div>}
|
||||
{totpQrOpen && typeof document !== 'undefined' ? createPortal((
|
||||
<div className="dialog-mask totp-scan-mask open" onClick={(event) => event.target === event.currentTarget && setTotpQrOpen(false)}>
|
||||
<section className="dialog-card totp-scan-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_scan_totp_qr')}>
|
||||
<div className="totp-scan-head">
|
||||
<h3 className="dialog-title">{t('txt_scan_totp_qr')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="totp-scan-close"
|
||||
onClick={() => setTotpQrOpen(false)}
|
||||
title={t('txt_close')}
|
||||
aria-label={t('txt_close')}
|
||||
>
|
||||
<X size={20} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="totp-scan-frame">
|
||||
<video ref={totpQrVideoRef} className="totp-scan-video" muted playsInline />
|
||||
<div className="totp-scan-corners" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="totp-scan-footer">
|
||||
<div className="dialog-message totp-scan-status">{totpQrStatus || t('txt_totp_qr_point_camera')}</div>
|
||||
<div className="actions totp-scan-actions">
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={totpQrBusy} onClick={() => totpQrFileRef.current?.click()}>
|
||||
<Upload size={14} className="btn-icon" />
|
||||
{t('txt_totp_qr_choose_image')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setTotpQrOpen(false)}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={totpQrFileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="attachment-file-input"
|
||||
onChange={(event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
void handleTotpQrFile(input.files?.[0] || null);
|
||||
input.value = '';
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CREATE_TYPE_OPTIONS,
|
||||
CreateTypeIcon,
|
||||
VAULT_SORT_OPTIONS,
|
||||
getCreateTypeOptions,
|
||||
getVaultSortOptions,
|
||||
VaultListIcon,
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
@@ -21,6 +24,7 @@ interface VirtualRange {
|
||||
interface VaultListPanelProps {
|
||||
busy: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
searchInput: string;
|
||||
sortMode: VaultSortMode;
|
||||
sortMenuOpen: boolean;
|
||||
@@ -32,6 +36,8 @@ interface VaultListPanelProps {
|
||||
selectedCipherId: string;
|
||||
selectedMap: Record<string, boolean>;
|
||||
sidebarFilter: SidebarFilter;
|
||||
isMobileLayout: boolean;
|
||||
mobileFabVisible: boolean;
|
||||
createMenuOpen: boolean;
|
||||
createMenuRef: RefObject<HTMLDivElement>;
|
||||
sortMenuRef: RefObject<HTMLDivElement>;
|
||||
@@ -59,7 +65,74 @@ interface VaultListPanelProps {
|
||||
listSubtitle: (cipher: Cipher) => string;
|
||||
}
|
||||
|
||||
interface CipherListItemProps {
|
||||
cipher: Cipher;
|
||||
selected: boolean;
|
||||
checked: boolean;
|
||||
subtitle: string;
|
||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||
onSelectCipher: (cipherId: string) => void;
|
||||
}
|
||||
|
||||
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={`list-item ${props.selected ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check')) return;
|
||||
props.onSelectCipher(props.cipher.id);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={props.checked}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInput={(e) => props.onToggleSelected(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(props.cipher.id)}>
|
||||
<div className="list-icon-wrap">
|
||||
<VaultListIcon cipher={props.cipher} />
|
||||
</div>
|
||||
<div className="list-text">
|
||||
<span className="list-title" title={props.cipher.decName || t('txt_no_name')}>
|
||||
<span className="list-title-text">{props.cipher.decName || t('txt_no_name')}</span>
|
||||
</span>
|
||||
<span className="list-sub" title={props.subtitle}>{props.subtitle}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
const createTypeOptions = getCreateTypeOptions();
|
||||
const vaultSortOptions = getVaultSortOptions();
|
||||
const createMenu = (
|
||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small mobile-fab-trigger"
|
||||
aria-label={t('txt_add')}
|
||||
title={t('txt_add')}
|
||||
onClick={props.onToggleCreateMenu}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
</button>
|
||||
{props.createMenuOpen && (
|
||||
<div className="create-menu">
|
||||
{createTypeOptions.map((option) => (
|
||||
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
||||
<CreateTypeIcon type={option.type} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="list-col">
|
||||
<div className="list-head">
|
||||
@@ -101,7 +174,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
</button>
|
||||
{props.sortMenuOpen && (
|
||||
<div className="sort-menu">
|
||||
{VAULT_SORT_OPTIONS.map((option) => (
|
||||
{vaultSortOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
@@ -159,66 +232,37 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small mobile-fab-trigger"
|
||||
aria-label={t('txt_add')}
|
||||
title={t('txt_add')}
|
||||
onClick={props.onToggleCreateMenu}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
</button>
|
||||
{props.createMenuOpen && (
|
||||
<div className="create-menu">
|
||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
||||
<CreateTypeIcon type={option.type} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.isMobileLayout && typeof document !== 'undefined'
|
||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
||||
: createMenu}
|
||||
</div>
|
||||
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher, index) => (
|
||||
<div
|
||||
key={cipher.id}
|
||||
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check')) return;
|
||||
props.onSelectCipher(cipher.id);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
||||
<div className="list-icon-wrap">
|
||||
<VaultListIcon cipher={cipher} />
|
||||
</div>
|
||||
<div className="list-text">
|
||||
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
|
||||
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
|
||||
</span>
|
||||
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
|
||||
</div>
|
||||
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
||||
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
||||
<div className="empty vault-error-state">
|
||||
<strong>{props.error}</strong>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onSyncVault}>
|
||||
{t('txt_retry_sync')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
<CipherListItem
|
||||
key={cipher.id}
|
||||
cipher={cipher}
|
||||
selected={props.selectedCipherId === cipher.id}
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
subtitle={props.listSubtitle(cipher)}
|
||||
onToggleSelected={props.onToggleSelected}
|
||||
onSelectCipher={props.onSelectCipher}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
{!props.loading && !props.error && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import type { RefObject } from 'preact';
|
||||
import {
|
||||
Archive,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Folder as FolderIcon,
|
||||
@@ -17,7 +21,7 @@ import {
|
||||
} from 'lucide-preact';
|
||||
import type { Folder } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { SidebarFilter } from '@/components/vault/vault-page-helpers';
|
||||
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultSidebarProps {
|
||||
folders: Folder[];
|
||||
@@ -25,15 +29,58 @@ interface VaultSidebarProps {
|
||||
busy: boolean;
|
||||
isMobileLayout: boolean;
|
||||
mobileSidebarOpen: boolean;
|
||||
folderSortMode: VaultSortMode;
|
||||
folderSortMenuOpen: boolean;
|
||||
folderSortMenuRef: RefObject<HTMLDivElement>;
|
||||
onCloseMobileSidebar: () => void;
|
||||
onChangeFilter: (filter: SidebarFilter) => void;
|
||||
onOpenDeleteAllFolders: () => void;
|
||||
onOpenCreateFolder: () => void;
|
||||
onOpenRenameFolder: (folder: Folder) => void;
|
||||
onOpenDeleteFolder: (folder: Folder) => void;
|
||||
onToggleFolderSortMenu: () => void;
|
||||
onSelectFolderSortMode: (value: VaultSortMode) => void;
|
||||
}
|
||||
|
||||
export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
const folderSortOptions = getFolderSortOptions();
|
||||
const nameCollator = useMemo(
|
||||
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
|
||||
[]
|
||||
);
|
||||
const sortedFolders = useMemo(() => {
|
||||
const sorted = [...props.folders];
|
||||
sorted.sort((a, b) => {
|
||||
if (props.folderSortMode === 'edited') {
|
||||
const aTime = new Date(String(a.revisionDate || a.creationDate || '')).getTime();
|
||||
const bTime = new Date(String(b.revisionDate || b.creationDate || '')).getTime();
|
||||
const aValid = Number.isFinite(aTime);
|
||||
const bValid = Number.isFinite(bTime);
|
||||
if (aValid && bValid) {
|
||||
const diff = bTime - aTime;
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
if (aValid !== bValid) return aValid ? -1 : 1;
|
||||
} else if (props.folderSortMode === 'created') {
|
||||
const aTime = new Date(String(a.creationDate || '')).getTime();
|
||||
const bTime = new Date(String(b.creationDate || '')).getTime();
|
||||
const aValid = Number.isFinite(aTime);
|
||||
const bValid = Number.isFinite(bTime);
|
||||
if (aValid && bValid) {
|
||||
const diff = bTime - aTime;
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
if (aValid !== bValid) return aValid ? -1 : 1;
|
||||
}
|
||||
const nameDiff = nameCollator.compare(
|
||||
String(a.decName || a.name || ''), String(b.decName || b.name || '')
|
||||
);
|
||||
if (nameDiff !== 0) return nameDiff;
|
||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
return sorted;
|
||||
}, [props.folders, props.folderSortMode, nameCollator]);
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
|
||||
{props.isMobileLayout && (
|
||||
@@ -85,6 +132,32 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
<div className="sidebar-title-row">
|
||||
<div className="sidebar-title">{t('txt_folders')}</div>
|
||||
<div className="folder-title-actions">
|
||||
<div className="sort-menu-wrap" ref={props.folderSortMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`folder-sort-btn ${props.folderSortMenuOpen ? 'active' : ''}`}
|
||||
title={t('txt_sort')}
|
||||
aria-label={t('txt_sort')}
|
||||
onClick={props.onToggleFolderSortMenu}
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
</button>
|
||||
{props.folderSortMenuOpen && (
|
||||
<div className="sort-menu">
|
||||
{folderSortOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`sort-menu-item ${props.folderSortMode === option.value ? 'active' : ''}`}
|
||||
onClick={() => props.onSelectFolderSortMode(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{props.folderSortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="folder-delete-btn"
|
||||
@@ -103,7 +176,7 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}>
|
||||
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
|
||||
</button>
|
||||
{props.folders.map((folder) => (
|
||||
{sortedFolders.map((folder) => (
|
||||
<div key={folder.id} className="folder-row">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { Globe } from 'lucide-preact';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import {
|
||||
getWebsiteIconImageUrl,
|
||||
getWebsiteIconStatus,
|
||||
preloadWebsiteIcon,
|
||||
subscribeWebsiteIconStatus,
|
||||
} from '@/lib/website-icon-cache';
|
||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
|
||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||
const SHOULD_LOAD_DEMO_BRAND_ICONS = __NODEWARDEN_DEMO__;
|
||||
|
||||
interface WebsiteIconProps {
|
||||
cipher: Cipher;
|
||||
fallback?: ComponentChildren;
|
||||
}
|
||||
|
||||
export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
||||
const src = host ? websiteIconUrl(host) : '';
|
||||
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
|
||||
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) {
|
||||
setShouldLoad(true);
|
||||
setStatus('idle');
|
||||
setImageUrl('');
|
||||
return;
|
||||
}
|
||||
const nextStatus = getWebsiteIconStatus(host);
|
||||
setShouldLoad(nextStatus === 'loaded');
|
||||
setStatus(nextStatus);
|
||||
setImageUrl(getWebsiteIconImageUrl(host));
|
||||
return subscribeWebsiteIconStatus(host, (next) => {
|
||||
setStatus(next);
|
||||
setImageUrl(getWebsiteIconImageUrl(host));
|
||||
});
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
||||
const node = nodeRef.current;
|
||||
if (!node) return;
|
||||
if (typeof IntersectionObserver !== 'function') {
|
||||
setShouldLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue;
|
||||
if (!cancelled) setShouldLoad(true);
|
||||
observer.disconnect();
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ rootMargin: ICON_LOAD_ROOT_MARGIN }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [host, shouldLoad, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||
if (demoIconUrl) return;
|
||||
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
|
||||
let disposed = false;
|
||||
void preloadWebsiteIcon(host, src).then((nextStatus) => {
|
||||
if (disposed) return;
|
||||
setStatus(nextStatus);
|
||||
setImageUrl(getWebsiteIconImageUrl(host));
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [demoIconUrl, host, src, shouldLoad, status]);
|
||||
|
||||
if (demoIconUrl) {
|
||||
return (
|
||||
<span className="list-icon-stack" ref={nodeRef}>
|
||||
<img
|
||||
className="list-icon loaded"
|
||||
src={demoIconUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host || status === 'error') {
|
||||
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="list-icon-stack" ref={nodeRef}>
|
||||
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
|
||||
{status === 'loaded' && imageUrl && (
|
||||
<img
|
||||
className="list-icon loaded"
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import {
|
||||
CreditCard,
|
||||
FileKey2,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
||||
import WebsiteIcon from './WebsiteIcon';
|
||||
|
||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||
@@ -27,31 +28,47 @@ interface TypeOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
||||
export function getCreateTypeOptions(): TypeOption[] {
|
||||
return [
|
||||
{ type: 1, label: t('txt_login') },
|
||||
{ type: 3, label: t('txt_card') },
|
||||
{ type: 4, label: t('txt_identity') },
|
||||
{ type: 2, label: t('txt_note') },
|
||||
{ type: 5, label: t('txt_ssh_key') },
|
||||
];
|
||||
}
|
||||
|
||||
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
export const VAULT_LIST_ROW_HEIGHT = 66;
|
||||
export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
|
||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||
export const VAULT_LIST_OVERSCAN = 10;
|
||||
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||
return [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
{ value: 'created', label: t('txt_sort_created') },
|
||||
{ value: 'name', label: t('txt_sort_name') },
|
||||
];
|
||||
}
|
||||
|
||||
export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
|
||||
export function getFolderSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||
return [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
{ value: 'created', label: t('txt_sort_created') },
|
||||
{ value: 'name', label: t('txt_sort_name') },
|
||||
];
|
||||
}
|
||||
|
||||
export function getFieldTypeOptions(): Array<{ value: CustomFieldType; label: string }> {
|
||||
return [
|
||||
{ value: 0, label: t('txt_text') },
|
||||
{ value: 1, label: t('txt_hidden') },
|
||||
{ value: 2, label: t('txt_boolean') },
|
||||
];
|
||||
}
|
||||
|
||||
export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string }> = [
|
||||
export function getWebsiteMatchOptions(): Array<{ value: number | null; label: string }> {
|
||||
return [
|
||||
{ value: null, label: t('txt_uri_match_default_base_domain') },
|
||||
{ value: 0, label: t('txt_uri_match_base_domain') },
|
||||
{ value: 1, label: t('txt_uri_match_host') },
|
||||
@@ -60,6 +77,7 @@ export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string
|
||||
{ value: 2, label: t('txt_uri_match_starts_with') },
|
||||
{ value: 4, label: t('txt_uri_match_regular_expression') },
|
||||
];
|
||||
}
|
||||
|
||||
export const TOTP_PERIOD_SECONDS = 30;
|
||||
export const TOTP_RING_RADIUS = 14;
|
||||
@@ -141,28 +159,7 @@ export function toBooleanFieldValue(raw: string): boolean {
|
||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||
}
|
||||
|
||||
export function firstCipherUri(cipher: Cipher): string {
|
||||
const uris = cipher.login?.uris || [];
|
||||
for (const uri of uris) {
|
||||
const raw = uri.decUri || uri.uri || '';
|
||||
if (raw.trim()) return raw.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function hostFromUri(uri: string): string {
|
||||
if (!uri.trim()) return '';
|
||||
try {
|
||||
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
||||
return new URL(normalized).hostname || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function websiteIconUrl(host: string): string {
|
||||
return `/icons/${encodeURIComponent(host)}/icon.png`;
|
||||
}
|
||||
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
|
||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||
@@ -170,7 +167,7 @@ export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||
|
||||
export function websiteMatchLabel(value: number | null | undefined): string {
|
||||
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
return WEBSITE_MATCH_OPTIONS.find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
|
||||
return getWebsiteMatchOptions().find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
|
||||
}
|
||||
|
||||
function valueOrFallback(value: string | null | undefined): string {
|
||||
@@ -427,32 +424,8 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedIconHosts = new Set<string>();
|
||||
|
||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||
const uri = firstCipherUri(cipher);
|
||||
const host = hostFromUri(uri);
|
||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||
if (host && !errored) {
|
||||
return (
|
||||
<img
|
||||
className="list-icon"
|
||||
src={websiteIconUrl(host)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => {
|
||||
failedIconHosts.add(host);
|
||||
setErrored(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="list-icon-fallback">
|
||||
<TypeIcon type={Number(cipher.type || 1)} />
|
||||
</span>
|
||||
);
|
||||
return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
|
||||
}
|
||||
|
||||
export function copyToClipboard(value: string): void {
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
deleteAuthorizedDevice,
|
||||
deriveLoginHash,
|
||||
getCurrentDeviceIdentifier,
|
||||
getApiKey,
|
||||
getTotpRecoveryCode,
|
||||
rotateApiKey,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
setTotp,
|
||||
@@ -129,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
try {
|
||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
|
||||
clearDisableTotpDialog();
|
||||
await refetchTotpStatus();
|
||||
onNotify('success', t('txt_totp_disabled'));
|
||||
@@ -148,6 +149,26 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
return code;
|
||||
},
|
||||
|
||||
async getApiKey(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const key = await getApiKey(authedFetch, derived.hash);
|
||||
if (!key) throw new Error(t('txt_api_key_is_empty'));
|
||||
return key;
|
||||
},
|
||||
|
||||
async rotateApiKey(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const key = await rotateApiKey(authedFetch, derived.hash);
|
||||
if (!key) throw new Error(t('txt_api_key_is_empty'));
|
||||
return key;
|
||||
},
|
||||
|
||||
async refreshAuthorizedDevices() {
|
||||
await refetchAuthorizedDevices();
|
||||
},
|
||||
|
||||
@@ -20,26 +20,39 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
refreshAdmin() {
|
||||
void refetchUsers();
|
||||
void refetchInvites();
|
||||
void Promise.all([refetchUsers(), refetchInvites()]).catch((error) => {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_load_admin_data_failed'));
|
||||
});
|
||||
},
|
||||
|
||||
async createInvite(hours: number) {
|
||||
try {
|
||||
await createInvite(authedFetch, hours);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invite_created'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
||||
try {
|
||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||
await refetchUsers();
|
||||
onNotify('success', t('txt_user_status_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async revokeInvite(code: string) {
|
||||
try {
|
||||
await revokeInvite(authedFetch, code);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invite_revoked'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAllInvites() {
|
||||
@@ -50,9 +63,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteAllInvites(authedFetch);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_all_invites_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_invites_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
@@ -66,9 +83,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteUser(authedFetch, userId);
|
||||
await refetchUsers();
|
||||
onNotify('success', t('txt_user_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_user_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
encryptZipBytesWithPassword,
|
||||
} from '@/lib/export-formats';
|
||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto';
|
||||
import { decryptSingleCipher } from '@/lib/decrypt-cipher';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
buildPublicSendUrl,
|
||||
@@ -66,6 +67,8 @@ interface UseVaultSendActionsOptions {
|
||||
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
|
||||
refetchSends: () => Promise<unknown>;
|
||||
onNotify: Notify;
|
||||
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
|
||||
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
|
||||
}
|
||||
|
||||
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
||||
@@ -82,6 +85,144 @@ function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
||||
return { byIndex, bySourceId };
|
||||
}
|
||||
|
||||
function createOptimisticCipherId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `optimistic:${crypto.randomUUID()}`;
|
||||
}
|
||||
return `optimistic:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null): Cipher {
|
||||
const now = new Date().toISOString();
|
||||
const type = Number(draft.type || current?.type || 1) || 1;
|
||||
const next: Cipher = {
|
||||
...(current || {}),
|
||||
id: current?.id || createOptimisticCipherId(),
|
||||
type,
|
||||
folderId: draft.folderId || null,
|
||||
favorite: !!draft.favorite,
|
||||
reprompt: draft.reprompt ? 1 : 0,
|
||||
name: draft.name || '',
|
||||
notes: draft.notes || '',
|
||||
decName: draft.name || '',
|
||||
decNotes: draft.notes || '',
|
||||
creationDate: current?.creationDate || now,
|
||||
revisionDate: now,
|
||||
deletedDate: current?.deletedDate || null,
|
||||
archivedDate: current?.archivedDate || null,
|
||||
};
|
||||
|
||||
if (type === 1) {
|
||||
next.login = {
|
||||
...(current?.login || {}),
|
||||
username: draft.loginUsername || '',
|
||||
password: draft.loginPassword || '',
|
||||
totp: draft.loginTotp || '',
|
||||
decUsername: draft.loginUsername || '',
|
||||
decPassword: draft.loginPassword || '',
|
||||
decTotp: draft.loginTotp || '',
|
||||
uris: draft.loginUris.map((uri) => ({
|
||||
...(uri.extra || {}),
|
||||
uri: uri.uri || '',
|
||||
decUri: uri.uri || '',
|
||||
match: uri.match ?? null,
|
||||
})),
|
||||
fido2Credentials: draft.loginFido2Credentials.map((credential) => ({ ...credential })),
|
||||
};
|
||||
} else {
|
||||
next.login = null;
|
||||
}
|
||||
|
||||
if (type === 3) {
|
||||
next.card = {
|
||||
...(current?.card || {}),
|
||||
cardholderName: draft.cardholderName || '',
|
||||
number: draft.cardNumber || '',
|
||||
brand: draft.cardBrand || '',
|
||||
expMonth: draft.cardExpMonth || '',
|
||||
expYear: draft.cardExpYear || '',
|
||||
code: draft.cardCode || '',
|
||||
decCardholderName: draft.cardholderName || '',
|
||||
decNumber: draft.cardNumber || '',
|
||||
decBrand: draft.cardBrand || '',
|
||||
decExpMonth: draft.cardExpMonth || '',
|
||||
decExpYear: draft.cardExpYear || '',
|
||||
decCode: draft.cardCode || '',
|
||||
};
|
||||
} else {
|
||||
next.card = null;
|
||||
}
|
||||
|
||||
if (type === 4) {
|
||||
next.identity = {
|
||||
...(current?.identity || {}),
|
||||
title: draft.identTitle || '',
|
||||
firstName: draft.identFirstName || '',
|
||||
middleName: draft.identMiddleName || '',
|
||||
lastName: draft.identLastName || '',
|
||||
username: draft.identUsername || '',
|
||||
company: draft.identCompany || '',
|
||||
ssn: draft.identSsn || '',
|
||||
passportNumber: draft.identPassportNumber || '',
|
||||
licenseNumber: draft.identLicenseNumber || '',
|
||||
email: draft.identEmail || '',
|
||||
phone: draft.identPhone || '',
|
||||
address1: draft.identAddress1 || '',
|
||||
address2: draft.identAddress2 || '',
|
||||
address3: draft.identAddress3 || '',
|
||||
city: draft.identCity || '',
|
||||
state: draft.identState || '',
|
||||
postalCode: draft.identPostalCode || '',
|
||||
country: draft.identCountry || '',
|
||||
decTitle: draft.identTitle || '',
|
||||
decFirstName: draft.identFirstName || '',
|
||||
decMiddleName: draft.identMiddleName || '',
|
||||
decLastName: draft.identLastName || '',
|
||||
decUsername: draft.identUsername || '',
|
||||
decCompany: draft.identCompany || '',
|
||||
decSsn: draft.identSsn || '',
|
||||
decPassportNumber: draft.identPassportNumber || '',
|
||||
decLicenseNumber: draft.identLicenseNumber || '',
|
||||
decEmail: draft.identEmail || '',
|
||||
decPhone: draft.identPhone || '',
|
||||
decAddress1: draft.identAddress1 || '',
|
||||
decAddress2: draft.identAddress2 || '',
|
||||
decAddress3: draft.identAddress3 || '',
|
||||
decCity: draft.identCity || '',
|
||||
decState: draft.identState || '',
|
||||
decPostalCode: draft.identPostalCode || '',
|
||||
decCountry: draft.identCountry || '',
|
||||
};
|
||||
} else {
|
||||
next.identity = null;
|
||||
}
|
||||
|
||||
if (type === 5) {
|
||||
next.sshKey = {
|
||||
...(current?.sshKey || {}),
|
||||
privateKey: draft.sshPrivateKey || '',
|
||||
publicKey: draft.sshPublicKey || '',
|
||||
keyFingerprint: draft.sshFingerprint || '',
|
||||
fingerprint: draft.sshFingerprint || '',
|
||||
decPrivateKey: draft.sshPrivateKey || '',
|
||||
decPublicKey: draft.sshPublicKey || '',
|
||||
decFingerprint: draft.sshFingerprint || '',
|
||||
};
|
||||
} else {
|
||||
next.sshKey = null;
|
||||
}
|
||||
|
||||
next.fields = draft.customFields.map((field) => ({
|
||||
type: field.type,
|
||||
name: field.label,
|
||||
value: field.value,
|
||||
decName: field.label,
|
||||
decValue: field.value,
|
||||
}));
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
|
||||
const {
|
||||
authedFetch,
|
||||
@@ -95,6 +236,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
refetchFolders,
|
||||
refetchSends,
|
||||
onNotify,
|
||||
patchDecryptedCiphers,
|
||||
patchDecryptedFolders,
|
||||
} = options;
|
||||
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
||||
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
|
||||
@@ -108,6 +251,91 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
||||
};
|
||||
|
||||
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
|
||||
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
||||
if (options?.includeFolders) {
|
||||
tasks.push(Promise.resolve(refetchFolders()));
|
||||
}
|
||||
void Promise.all(tasks).catch((err) => {
|
||||
console.warn('Background vault sync failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
async function decryptAndPatch(encrypted: Cipher) {
|
||||
if (!session?.symEncKey || !session?.symMacKey) {
|
||||
await refetchCiphers();
|
||||
return;
|
||||
}
|
||||
const encKey = base64ToBytes(session.symEncKey);
|
||||
const macKey = base64ToBytes(session.symMacKey);
|
||||
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
|
||||
patchDecryptedCiphers((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === decrypted.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = decrypted;
|
||||
return next;
|
||||
}
|
||||
return [decrypted, ...prev];
|
||||
});
|
||||
}
|
||||
|
||||
async function decryptAndReplaceOptimistic(optimisticId: string, encrypted: Cipher) {
|
||||
if (!session?.symEncKey || !session?.symMacKey) {
|
||||
await refetchCiphers();
|
||||
return;
|
||||
}
|
||||
const encKey = base64ToBytes(session.symEncKey);
|
||||
const macKey = base64ToBytes(session.symMacKey);
|
||||
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
|
||||
patchDecryptedCiphers((prev) => {
|
||||
const next = prev.filter((cipher) => cipher.id !== optimisticId && cipher.id !== decrypted.id);
|
||||
return [decrypted, ...next];
|
||||
});
|
||||
}
|
||||
|
||||
function removeCipherFromState(id: string) {
|
||||
patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) {
|
||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
if (!idSet.size) return;
|
||||
patchDecryptedCiphers((prev) => {
|
||||
let changed = false;
|
||||
const next: Cipher[] = [];
|
||||
for (const cipher of prev) {
|
||||
if (!idSet.has(cipher.id)) {
|
||||
next.push(cipher);
|
||||
continue;
|
||||
}
|
||||
const updated = updater(cipher);
|
||||
changed = true;
|
||||
if (updated) next.push(updated);
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) {
|
||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
if (!idSet.size) return;
|
||||
patchDecryptedFolders((prev) => {
|
||||
let changed = false;
|
||||
const next: VaultFolder[] = [];
|
||||
for (const folder of prev) {
|
||||
if (!idSet.has(folder.id)) {
|
||||
next.push(folder);
|
||||
continue;
|
||||
}
|
||||
const updated = updater(folder);
|
||||
changed = true;
|
||||
if (updated) next.push(updated);
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
const uploadImportedAttachments = async (
|
||||
attachments: ImportAttachmentFile[],
|
||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||
@@ -168,6 +396,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||
if (!session) return;
|
||||
const optimistic = optimisticCipherFromDraft(draft, null);
|
||||
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
|
||||
try {
|
||||
const created = await createCipher(authedFetch, session, draft);
|
||||
for (const file of attachments) {
|
||||
@@ -175,9 +405,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
setAttachmentUploadPercent(0);
|
||||
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
|
||||
}
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
await decryptAndReplaceOptimistic(optimistic.id, created);
|
||||
syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 });
|
||||
onNotify('success', t('txt_item_created'));
|
||||
} catch (error) {
|
||||
patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id));
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -190,8 +422,26 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
if (!session) return;
|
||||
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
||||
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
||||
const previousCipher: Cipher = {
|
||||
...cipher,
|
||||
login: cipher.login ? { ...cipher.login, uris: cipher.login.uris ? [...cipher.login.uris] : cipher.login.uris } : cipher.login,
|
||||
card: cipher.card ? { ...cipher.card } : cipher.card,
|
||||
identity: cipher.identity ? { ...cipher.identity } : cipher.identity,
|
||||
sshKey: cipher.sshKey ? { ...cipher.sshKey } : cipher.sshKey,
|
||||
fields: cipher.fields ? cipher.fields.map((field) => ({ ...field })) : cipher.fields,
|
||||
attachments: cipher.attachments ? cipher.attachments.map((attachment) => ({ ...attachment })) : cipher.attachments,
|
||||
passwordHistory: cipher.passwordHistory ? cipher.passwordHistory.map((entry) => ({ ...entry })) : cipher.passwordHistory,
|
||||
};
|
||||
const optimistic = optimisticCipherFromDraft(draft, cipher);
|
||||
if (removeAttachmentIds.length || addFiles.length) {
|
||||
const removedSet = new Set(removeAttachmentIds.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
optimistic.attachments = (cipher.attachments || [])
|
||||
.filter((attachment) => !removedSet.has(String(attachment?.id || '').trim()))
|
||||
.map((attachment) => ({ ...attachment }));
|
||||
}
|
||||
patchCipherBatch([cipher.id], () => optimistic);
|
||||
try {
|
||||
await updateCipher(authedFetch, session, cipher, draft);
|
||||
const updated = await updateCipher(authedFetch, session, cipher, draft);
|
||||
for (const attachmentId of removeAttachmentIds) {
|
||||
const id = String(attachmentId || '').trim();
|
||||
if (!id) continue;
|
||||
@@ -202,9 +452,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
setAttachmentUploadPercent(0);
|
||||
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent);
|
||||
}
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
await decryptAndPatch(updated);
|
||||
syncVaultCoreInBackground({
|
||||
includeFolders:
|
||||
draft.folderId !== (cipher.folderId || '')
|
||||
|| addFiles.length > 0
|
||||
|| removeAttachmentIds.length > 0,
|
||||
});
|
||||
onNotify('success', t('txt_item_updated'));
|
||||
} catch (error) {
|
||||
patchCipherBatch([cipher.id], () => previousCipher);
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -232,33 +489,48 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async deleteVaultItem(cipher: Cipher) {
|
||||
const previousCipher = { ...cipher };
|
||||
const deletedDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate }));
|
||||
try {
|
||||
await deleteCipher(authedFetch, cipher.id);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const deleted = await deleteCipher(authedFetch, cipher.id);
|
||||
await decryptAndPatch(deleted);
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_item_deleted'));
|
||||
} catch (error) {
|
||||
patchCipherBatch([cipher.id], () => previousCipher);
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async archiveVaultItem(cipher: Cipher) {
|
||||
const previousCipher = { ...cipher };
|
||||
const archivedDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
|
||||
try {
|
||||
await archiveCipher(authedFetch, cipher.id);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const archived = await archiveCipher(authedFetch, cipher.id);
|
||||
await decryptAndPatch(archived);
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_item_archived'));
|
||||
} catch (error) {
|
||||
patchCipherBatch([cipher.id], () => previousCipher);
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async unarchiveVaultItem(cipher: Cipher) {
|
||||
const previousCipher = { ...cipher };
|
||||
const revisionDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
|
||||
try {
|
||||
await unarchiveCipher(authedFetch, cipher.id);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const unarchived = await unarchiveCipher(authedFetch, cipher.id);
|
||||
await decryptAndPatch(unarchived);
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_item_unarchived'));
|
||||
} catch (error) {
|
||||
patchCipherBatch([cipher.id], () => previousCipher);
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
|
||||
throw error;
|
||||
}
|
||||
@@ -267,7 +539,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkDeleteCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const deletedDate = new Date().toISOString();
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_deleted_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
||||
@@ -278,7 +552,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkArchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkArchiveCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const archivedDate = new Date().toISOString();
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_archived_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
|
||||
@@ -289,7 +565,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkUnarchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkUnarchiveCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_unarchived_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
|
||||
@@ -300,7 +577,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||
try {
|
||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_moved_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
||||
@@ -316,8 +594,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await createFolder(authedFetch, session, folderName);
|
||||
await refetchFolders();
|
||||
const created = await createFolder(authedFetch, session, folderName);
|
||||
patchDecryptedFolders((prev) => [
|
||||
{
|
||||
id: created.id,
|
||||
name: created.name || folderName,
|
||||
decName: folderName,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_folder_created'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
||||
@@ -333,7 +619,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
}
|
||||
try {
|
||||
await deleteFolder(authedFetch, id);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchFolderBatch([id], () => null);
|
||||
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_folder_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
||||
@@ -355,7 +643,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await updateFolder(authedFetch, session, id, nextName);
|
||||
await refetchFolders();
|
||||
patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_folder_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
||||
@@ -366,7 +655,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_restored_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
||||
@@ -377,7 +667,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchCipherBatch(ids, () => null);
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
||||
@@ -390,7 +681,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
await bulkDeleteFolders(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const removedIds = new Set(ids);
|
||||
patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
|
||||
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_folders_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
||||
@@ -524,7 +818,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
for (let i = 0; i < payload.ciphers.length; i++) {
|
||||
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
|
||||
const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null);
|
||||
nextPayload.ciphers.push(await buildCipherImportPayload(session, draft));
|
||||
const cipherPayload = await buildCipherImportPayload(session, draft);
|
||||
const sourceId = String(raw.id || '').trim();
|
||||
if (sourceId) cipherPayload.id = sourceId;
|
||||
nextPayload.ciphers.push(cipherPayload);
|
||||
}
|
||||
|
||||
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
|
||||
|
||||
@@ -46,6 +46,8 @@ interface RefreshSuccess {
|
||||
|
||||
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||
|
||||
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
@@ -122,9 +124,11 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Profile;
|
||||
if (!parsed?.email || !parsed?.key) return null;
|
||||
if (!parsed?.email) return null;
|
||||
if (email && parsed.email !== email) return null;
|
||||
return parsed;
|
||||
const snapshot = stripProfileSecrets(parsed);
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||||
return snapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -132,13 +136,48 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
|
||||
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||
if (!profile) return;
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
||||
const nextSnapshot = stripProfileSecrets(profile);
|
||||
try {
|
||||
const rawExisting = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||
if (rawExisting) {
|
||||
const existing = stripProfileSecrets(JSON.parse(rawExisting) as Profile);
|
||||
if (
|
||||
existing
|
||||
&& existing.email === nextSnapshot?.email
|
||||
&& existing.role === 'admin'
|
||||
&& nextSnapshot?.role !== 'admin'
|
||||
) {
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify({
|
||||
...nextSnapshot,
|
||||
role: 'admin',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to writing the normalized snapshot below.
|
||||
}
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(nextSnapshot));
|
||||
}
|
||||
|
||||
export function clearProfileSnapshot(): void {
|
||||
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
||||
}
|
||||
|
||||
export function stripProfileSecrets(profile: Profile | null): Profile | null {
|
||||
if (!profile) return null;
|
||||
return {
|
||||
id: String(profile.id || ''),
|
||||
email: String(profile.email || ''),
|
||||
name: String(profile.name || ''),
|
||||
role: profile.role === 'admin' ? 'admin' : 'user',
|
||||
masterPasswordHint: profile.masterPasswordHint ?? null,
|
||||
publicKey: profile.publicKey ?? null,
|
||||
key: '',
|
||||
privateKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentDeviceIdentifier(): string {
|
||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||
}
|
||||
@@ -275,6 +314,25 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
|
||||
}
|
||||
}
|
||||
|
||||
function refreshKey(session: SessionState): string {
|
||||
if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`;
|
||||
return `token:${session.refreshToken || ''}`;
|
||||
}
|
||||
|
||||
function refreshAccessTokenOnce(session: SessionState): Promise<RefreshResult> {
|
||||
const key = refreshKey(session);
|
||||
const existing = pendingRefreshes.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = refreshAccessToken(session).finally(() => {
|
||||
if (pendingRefreshes.get(key) === request) {
|
||||
pendingRefreshes.delete(key);
|
||||
}
|
||||
});
|
||||
pendingRefreshes.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||
const body = new URLSearchParams();
|
||||
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||
@@ -366,15 +424,49 @@ export async function getPasswordHint(email: string): Promise<{ masterPasswordHi
|
||||
|
||||
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
|
||||
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const retryableRequest = async (headers: Headers): Promise<Response> => {
|
||||
const maxAttempts = 3;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(input, { ...init, headers });
|
||||
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
||||
return response;
|
||||
}
|
||||
lastError = new Error(`HTTP ${response.status}`);
|
||||
if (attempt === maxAttempts - 1) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === maxAttempts - 1) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const delayMs = 250 * (2 ** attempt) + Math.floor(Math.random() * 120);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('Request failed');
|
||||
};
|
||||
|
||||
const session = getSession();
|
||||
if (!session?.accessToken) throw new Error('Unauthorized');
|
||||
const headers = new Headers(init.headers || {});
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
|
||||
let resp = await fetch(input, { ...init, headers });
|
||||
let resp = await retryableRequest(headers);
|
||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
const latest = getSession();
|
||||
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
||||
const latestHeaders = new Headers(init.headers || {});
|
||||
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
||||
resp = await retryableRequest(latestHeaders);
|
||||
if (resp.status !== 401) return resp;
|
||||
}
|
||||
|
||||
const refreshSource = latest || session;
|
||||
const refreshed = await refreshAccessTokenOnce(refreshSource);
|
||||
if (!refreshed.ok) {
|
||||
if (refreshed.transient) {
|
||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||
@@ -384,17 +476,17 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
}
|
||||
|
||||
const nextSession: SessionState = {
|
||||
...session,
|
||||
...refreshSource,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'),
|
||||
};
|
||||
setSession(nextSession);
|
||||
saveSession(nextSession);
|
||||
|
||||
const retryHeaders = new Headers(init.headers || {});
|
||||
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
||||
resp = await fetch(input, { ...init, headers: retryHeaders });
|
||||
resp = await retryableRequest(retryHeaders);
|
||||
return resp;
|
||||
};
|
||||
}
|
||||
@@ -502,6 +594,19 @@ export async function verifyMasterPassword(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
|
||||
const resp = await authedFetch('/api/accounts/revision-date');
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to load revision date');
|
||||
}
|
||||
const body = await parseJson<number>(resp);
|
||||
const stamp = Number(body);
|
||||
if (!Number.isFinite(stamp) || stamp <= 0) {
|
||||
throw new Error('Invalid revision date');
|
||||
}
|
||||
return stamp;
|
||||
}
|
||||
|
||||
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
||||
const resp = await authedFetch('/api/accounts/totp');
|
||||
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
||||
@@ -594,3 +699,31 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom
|
||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||
}
|
||||
|
||||
export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
||||
const resp = await authedFetch('/api/accounts/api-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Failed to get API key');
|
||||
}
|
||||
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||
return String(body.apiKey || '');
|
||||
}
|
||||
|
||||
export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
||||
const resp = await authedFetch('/api/accounts/rotate-api-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Failed to rotate API key');
|
||||
}
|
||||
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||
return String(body.apiKey || '');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings as AdminBackupSettings,
|
||||
E3BackupDestination,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '@shared/backup-schema';
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ export type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
AdminBackupSettings,
|
||||
E3BackupDestination,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
||||
import type { Send, SendDraft, SessionState } from '../types';
|
||||
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
||||
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||
|
||||
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -62,8 +61,10 @@ function parseMaxAccessCountRaw(value: string): number | null {
|
||||
}
|
||||
|
||||
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
return body.sends || [];
|
||||
const resp = await authedFetch('/api/sends');
|
||||
if (!resp.ok) throw new Error('Failed to load sends');
|
||||
const body = await parseJson<{ data?: Send[] }>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function createSend(
|
||||
@@ -260,18 +261,24 @@ async function buildPublicSendAccessPayload(password?: string, keyPart?: string
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function accessPublicSend(accessId: string, keyPart?: string | null, password?: string): Promise<any> {
|
||||
export async function accessPublicSend(
|
||||
accessId: string,
|
||||
keyPart?: string | null,
|
||||
password?: string,
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<unknown> {
|
||||
const payload = await buildPublicSendAccessPayload(password, keyPart);
|
||||
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const message = await parseErrorMessage(resp, 'Failed to access send');
|
||||
throw createApiError(message, resp.status);
|
||||
}
|
||||
return (await parseJson<any>(resp)) || null;
|
||||
return (await parseJson<unknown>(resp)) || null;
|
||||
}
|
||||
|
||||
export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> {
|
||||
@@ -290,19 +297,22 @@ export async function accessPublicSendFile(sendId: string, fileId: string, keyPa
|
||||
return body.url;
|
||||
}
|
||||
|
||||
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> {
|
||||
export async function decryptPublicSend(accessData: unknown, urlSafeKey: string): Promise<unknown> {
|
||||
const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
|
||||
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||
const out: any = { ...accessData };
|
||||
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
|
||||
if (accessData?.text?.text) {
|
||||
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac);
|
||||
const source = accessData && typeof accessData === 'object' ? accessData as Record<string, unknown> : {};
|
||||
const text = source.text && typeof source.text === 'object' ? source.text as Record<string, unknown> : null;
|
||||
const file = source.file && typeof source.file === 'object' ? source.file as Record<string, unknown> : null;
|
||||
const out: Record<string, unknown> = { ...source };
|
||||
out.decName = await decryptStr(String(source.name || ''), sendKey.enc, sendKey.mac);
|
||||
if (text?.text) {
|
||||
out.decText = await decryptStr(String(text.text), sendKey.enc, sendKey.mac);
|
||||
}
|
||||
if (accessData?.file?.fileName) {
|
||||
if (file?.fileName) {
|
||||
try {
|
||||
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac);
|
||||
out.decFileName = await decryptStr(String(file.fileName), sendKey.enc, sendKey.mac);
|
||||
} catch {
|
||||
out.decFileName = String(accessData.file.fileName);
|
||||
out.decFileName = String(file.fileName);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Cipher, Folder, Send } from '../types';
|
||||
import { getVaultRevisionDate } from './auth';
|
||||
import { loadCachedVaultCoreSnapshot, saveCachedVaultCoreSnapshot, type VaultCoreSnapshot } from '../vault-cache';
|
||||
import { parseJson, type AuthedFetch } from './shared';
|
||||
|
||||
interface VaultSyncResponse {
|
||||
@@ -7,25 +9,104 @@ interface VaultSyncResponse {
|
||||
sends?: Send[];
|
||||
}
|
||||
|
||||
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
|
||||
const pendingVaultCoreRequests = new Map<string, Promise<VaultCoreSnapshot>>();
|
||||
const memoryVaultCoreCache = new Map<string, { revisionStamp: number; snapshot: VaultCoreSnapshot }>();
|
||||
|
||||
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
|
||||
const existing = pendingSyncRequests.get(authedFetch);
|
||||
function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCoreSnapshot {
|
||||
return {
|
||||
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
|
||||
folders: Array.isArray(body?.folders) ? body!.folders! : [],
|
||||
sends: Array.isArray(body?.sends) ? body!.sends! : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCachedSnapshot(snapshot: Partial<VaultCoreSnapshot> | null | undefined): VaultCoreSnapshot {
|
||||
return {
|
||||
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
|
||||
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
|
||||
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<VaultCoreSnapshot | null> {
|
||||
const normalizedKey = String(cacheKey || '').trim();
|
||||
if (!normalizedKey) return null;
|
||||
const memory = memoryVaultCoreCache.get(normalizedKey);
|
||||
if (memory) return memory.snapshot;
|
||||
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
if (!cached?.snapshot) return null;
|
||||
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||
memoryVaultCoreCache.set(normalizedKey, {
|
||||
revisionStamp: cached.revisionStamp,
|
||||
snapshot,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
||||
const normalizedKey = String(cacheKey || '').trim();
|
||||
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
||||
|
||||
const existing = pendingVaultCoreRequests.get(normalizedKey);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = (async () => {
|
||||
const resp = await authedFetch('/api/sync');
|
||||
const memory = memoryVaultCoreCache.get(normalizedKey);
|
||||
let cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
if (!memory && cached?.snapshot) {
|
||||
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||
memoryVaultCoreCache.set(normalizedKey, {
|
||||
revisionStamp: cached.revisionStamp,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const revisionStamp = await getVaultRevisionDate(authedFetch);
|
||||
const currentMemory = memoryVaultCoreCache.get(normalizedKey);
|
||||
if (currentMemory?.revisionStamp === revisionStamp) {
|
||||
return currentMemory.snapshot;
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
}
|
||||
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
|
||||
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||
memoryVaultCoreCache.set(normalizedKey, {
|
||||
revisionStamp,
|
||||
snapshot,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/sync', {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to load vault');
|
||||
const body = await parseJson<VaultSyncResponse>(resp);
|
||||
return body || {};
|
||||
const snapshot = normalizeSnapshot(body);
|
||||
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
|
||||
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
const fallbackMemory = memoryVaultCoreCache.get(normalizedKey);
|
||||
if (fallbackMemory?.snapshot) return fallbackMemory.snapshot;
|
||||
if (cached?.snapshot) return normalizeCachedSnapshot(cached.snapshot);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
pendingSyncRequests.set(authedFetch, request);
|
||||
pendingVaultCoreRequests.set(normalizedKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (pendingSyncRequests.get(authedFetch) === request) {
|
||||
pendingSyncRequests.delete(authedFetch);
|
||||
if (pendingVaultCoreRequests.get(normalizedKey) === request) {
|
||||
pendingVaultCoreRequests.delete(normalizedKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
parseErrorMessage,
|
||||
parseJson,
|
||||
uploadDirectEncryptedPayload,
|
||||
uploadWithProgress,
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||
import { loadVaultCoreSyncSnapshot } from './vault-sync';
|
||||
|
||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
|
||||
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
|
||||
return body.folders || [];
|
||||
}
|
||||
|
||||
@@ -91,8 +92,8 @@ export async function updateFolder(
|
||||
if (!resp.ok) throw new Error('Update folder failed');
|
||||
}
|
||||
|
||||
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise<Cipher[]> {
|
||||
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
|
||||
return body.ciphers || [];
|
||||
}
|
||||
|
||||
@@ -273,6 +274,98 @@ export async function deleteCipherAttachment(
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed'));
|
||||
}
|
||||
|
||||
export async function repairCipherAttachmentMetadata(
|
||||
authedFetch: AuthedFetch,
|
||||
cipherId: string,
|
||||
attachmentId: string,
|
||||
metadata: { fileName?: string; key?: string | null }
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(
|
||||
`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}/metadata`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(metadata),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Update attachment metadata failed'));
|
||||
}
|
||||
|
||||
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.byteLength !== b.byteLength) return false;
|
||||
for (let i = 0; i < a.byteLength; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function decryptCipherStringWithKey(
|
||||
value: string,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Uint8Array | null> {
|
||||
try {
|
||||
return await decryptBw(value, enc, mac);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptAttachmentFileName(
|
||||
rawFileName: string,
|
||||
itemKeys: { enc: Uint8Array; mac: Uint8Array },
|
||||
userKeys: { enc: Uint8Array; mac: Uint8Array }
|
||||
): Promise<{ fileName: string; source: 'plain' | 'item' | 'user' }> {
|
||||
const fallback = rawFileName || 'attachment.bin';
|
||||
if (!rawFileName || !looksLikeCipherString(rawFileName)) return { fileName: fallback, source: 'plain' };
|
||||
|
||||
try {
|
||||
const fileName = await decryptStr(rawFileName, itemKeys.enc, itemKeys.mac);
|
||||
if (fileName) return { fileName, source: 'item' };
|
||||
} catch {
|
||||
// 继续尝试旧 user key 文件名。
|
||||
}
|
||||
|
||||
if (!sameBytes(itemKeys.enc, userKeys.enc) || !sameBytes(itemKeys.mac, userKeys.mac)) {
|
||||
try {
|
||||
const fileName = await decryptStr(rawFileName, userKeys.enc, userKeys.mac);
|
||||
if (fileName) return { fileName, source: 'user' };
|
||||
} catch {
|
||||
// 保留原始文件名。
|
||||
}
|
||||
}
|
||||
|
||||
return { fileName: fallback, source: 'plain' };
|
||||
}
|
||||
|
||||
type AttachmentDecryptMode = 'attachment-item' | 'attachment-user' | 'legacy-item' | 'legacy-user';
|
||||
|
||||
interface AttachmentDecryptCandidate {
|
||||
mode: AttachmentDecryptMode;
|
||||
enc: Uint8Array;
|
||||
mac: Uint8Array;
|
||||
rawAttachmentKey: Uint8Array | null;
|
||||
}
|
||||
|
||||
async function uploadRepairedAttachmentBlob(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
cipherId: string,
|
||||
attachmentId: string,
|
||||
encryptedBytes: Uint8Array
|
||||
): Promise<void> {
|
||||
if (!session.accessToken) throw new Error('Unauthorized');
|
||||
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedBytes);
|
||||
const resp = await uploadWithProgress(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`, {
|
||||
accessToken: session.accessToken,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: payload,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair attachment upload failed'));
|
||||
}
|
||||
|
||||
export async function downloadCipherAttachmentDecrypted(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
@@ -293,32 +386,76 @@ export async function downloadCipherAttachmentDecrypted(
|
||||
const userEnc = base64ToBytes(session.symEncKey);
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
|
||||
const userKeys = { enc: userEnc, mac: userMac };
|
||||
|
||||
let fileEnc = itemKeys.enc;
|
||||
let fileMac = itemKeys.mac;
|
||||
const candidates: AttachmentDecryptCandidate[] = [];
|
||||
const keyCipher = String(info.key || '').trim();
|
||||
if (keyCipher && looksLikeCipherString(keyCipher)) {
|
||||
try {
|
||||
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac);
|
||||
if (fileRawKey.length >= 64) {
|
||||
fileEnc = fileRawKey.slice(0, 32);
|
||||
fileMac = fileRawKey.slice(32, 64);
|
||||
}
|
||||
} catch {
|
||||
// fallback to item key
|
||||
}
|
||||
const itemWrappedKey = await decryptCipherStringWithKey(keyCipher, itemKeys.enc, itemKeys.mac);
|
||||
if (itemWrappedKey && itemWrappedKey.length >= 64) {
|
||||
candidates.push({
|
||||
mode: 'attachment-item',
|
||||
enc: itemWrappedKey.slice(0, 32),
|
||||
mac: itemWrappedKey.slice(32, 64),
|
||||
rawAttachmentKey: itemWrappedKey,
|
||||
});
|
||||
}
|
||||
|
||||
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
|
||||
if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) {
|
||||
const userWrappedKey = await decryptCipherStringWithKey(keyCipher, userEnc, userMac);
|
||||
if (userWrappedKey && userWrappedKey.length >= 64) {
|
||||
candidates.push({
|
||||
mode: 'attachment-user',
|
||||
enc: userWrappedKey.slice(0, 32),
|
||||
mac: userWrappedKey.slice(32, 64),
|
||||
rawAttachmentKey: userWrappedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.push({ mode: 'legacy-item', enc: itemKeys.enc, mac: itemKeys.mac, rawAttachmentKey: null });
|
||||
if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) {
|
||||
candidates.push({ mode: 'legacy-user', enc: userEnc, mac: userMac, rawAttachmentKey: null });
|
||||
}
|
||||
|
||||
let plainBytes: Uint8Array | null = null;
|
||||
let usedCandidate: AttachmentDecryptCandidate | null = null;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
plainBytes = await decryptBwFileData(encryptedBytes, candidate.enc, candidate.mac);
|
||||
usedCandidate = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// 继续尝试下一种旧附件格式。
|
||||
}
|
||||
}
|
||||
if (!plainBytes || !usedCandidate) throw new Error('Attachment decryption failed');
|
||||
|
||||
const fileNameRaw = String(info.fileName || '').trim();
|
||||
let fileName = fileNameRaw || `attachment-${aid}`;
|
||||
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
|
||||
const nameResult = await decryptAttachmentFileName(fileNameRaw, itemKeys, userKeys);
|
||||
const fileName = nameResult.fileName || `attachment-${aid}`;
|
||||
|
||||
try {
|
||||
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName;
|
||||
} catch {
|
||||
// keep fallback name
|
||||
const metadata: { fileName?: string; key?: string | null } = {};
|
||||
if (nameResult.source === 'user') {
|
||||
metadata.fileName = await encryptTextValue(fileName, itemKeys.enc, itemKeys.mac) || undefined;
|
||||
}
|
||||
|
||||
if (usedCandidate.mode === 'attachment-user' && usedCandidate.rawAttachmentKey) {
|
||||
metadata.key = await encryptBw(usedCandidate.rawAttachmentKey, itemKeys.enc, itemKeys.mac);
|
||||
} else if (usedCandidate.mode === 'legacy-item') {
|
||||
metadata.key = null;
|
||||
} else if (usedCandidate.mode === 'legacy-user') {
|
||||
const repairedBytes = await encryptBwFileData(plainBytes, itemKeys.enc, itemKeys.mac);
|
||||
await uploadRepairedAttachmentBlob(authedFetch, session, cid, aid, repairedBytes);
|
||||
metadata.key = null;
|
||||
}
|
||||
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
await repairCipherAttachmentMetadata(authedFetch, cid, aid, metadata);
|
||||
}
|
||||
} catch {
|
||||
// 修复失败不影响本次下载,旧附件内容已经成功解密。
|
||||
}
|
||||
|
||||
return { fileName, bytes: plainBytes };
|
||||
@@ -426,9 +563,13 @@ async function encryptUris(
|
||||
mac: Uint8Array
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of uris || []) {
|
||||
const trimmed = String(entry?.uri || '').trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const preservedExtra =
|
||||
entry?.extra && typeof entry.extra === 'object'
|
||||
? { ...entry.extra }
|
||||
@@ -625,7 +766,7 @@ export async function createCipher(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
draft: VaultDraft
|
||||
): Promise<{ id: string }> {
|
||||
): Promise<Cipher> {
|
||||
const payload = await buildCipherPayload(session, draft, null);
|
||||
|
||||
const resp = await authedFetch('/api/ciphers', {
|
||||
@@ -634,9 +775,9 @@ export async function createCipher(
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Create item failed');
|
||||
const body = await parseJson<{ id?: string }>(resp);
|
||||
const body = await parseJson<Cipher>(resp);
|
||||
if (!body?.id) throw new Error('Create item failed');
|
||||
return { id: body.id };
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function updateCipher(
|
||||
@@ -644,7 +785,7 @@ export async function updateCipher(
|
||||
session: SessionState,
|
||||
cipher: Cipher,
|
||||
draft: VaultDraft
|
||||
): Promise<void> {
|
||||
): Promise<Cipher> {
|
||||
const payload = await buildCipherPayload(session, draft, cipher);
|
||||
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||
@@ -653,25 +794,29 @@ export async function updateCipher(
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update item failed');
|
||||
return (await parseJson<Cipher>(resp))!;
|
||||
}
|
||||
|
||||
export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
|
||||
export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Delete item failed');
|
||||
return (await parseJson<Cipher>(resp))!;
|
||||
}
|
||||
|
||||
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
|
||||
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
|
||||
const id = String(cipherId || '').trim();
|
||||
if (!id) throw new Error('Cipher id is required');
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' });
|
||||
if (!resp.ok) throw new Error('Archive item failed');
|
||||
return (await parseJson<Cipher>(resp))!;
|
||||
}
|
||||
|
||||
export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
|
||||
export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
|
||||
const id = String(cipherId || '').trim();
|
||||
if (!id) throw new Error('Cipher id is required');
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' });
|
||||
if (!resp.ok) throw new Error('Unarchive item failed');
|
||||
return (await parseJson<Cipher>(resp))!;
|
||||
}
|
||||
|
||||
export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
|
||||
|
||||
@@ -144,7 +144,7 @@ function decodeAccessTokenClaims(accessToken: string): AccessTokenClaims {
|
||||
}
|
||||
}
|
||||
|
||||
function buildTransientProfile(token: TokenSuccess, email: string): Profile {
|
||||
function buildTransientProfile(token: TokenSuccess, email: string, fallbackProfile: Profile | null = null): Profile {
|
||||
const claims = decodeAccessTokenClaims(token.access_token);
|
||||
const normalizedEmail = String(claims.email || email || '').trim().toLowerCase();
|
||||
const accountKeys = token.accountKeys ?? token.AccountKeys ?? null;
|
||||
@@ -154,9 +154,11 @@ function buildTransientProfile(token: TokenSuccess, email: string): Profile {
|
||||
name: String(claims.name || normalizedEmail || ''),
|
||||
key: String(token.Key || ''),
|
||||
privateKey: token.PrivateKey ?? null,
|
||||
role: 'user',
|
||||
role: fallbackProfile?.role === 'admin' ? 'admin' : 'user',
|
||||
premium: !!claims.premium,
|
||||
accountKeys,
|
||||
masterPasswordHint: fallbackProfile?.masterPasswordHint ?? null,
|
||||
publicKey: fallbackProfile?.publicKey ?? null,
|
||||
object: 'profile',
|
||||
};
|
||||
}
|
||||
@@ -256,6 +258,7 @@ export async function completeLogin(
|
||||
masterKey: Uint8Array
|
||||
): Promise<CompletedLogin> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||
const baseSession: SessionState = {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
@@ -266,7 +269,7 @@ export async function completeLogin(
|
||||
() => baseSession,
|
||||
() => {}
|
||||
);
|
||||
const profile = buildTransientProfile(token, normalizedEmail);
|
||||
const profile = buildTransientProfile(token, normalizedEmail, fallbackProfile);
|
||||
if (!profile.key) {
|
||||
throw new Error('Missing profile key');
|
||||
}
|
||||
@@ -372,16 +375,36 @@ export async function performRegistration(args: {
|
||||
|
||||
export async function performUnlock(
|
||||
session: SessionState,
|
||||
profile: Profile,
|
||||
profile: Profile | null,
|
||||
password: string,
|
||||
fallbackIterations: number
|
||||
): Promise<SessionState> {
|
||||
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations);
|
||||
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession) {
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return { ...refreshedSession, ...keys };
|
||||
): Promise<PasswordLoginResult> {
|
||||
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
};
|
||||
}
|
||||
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
return {
|
||||
kind: 'totp',
|
||||
pendingTotp: {
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'error',
|
||||
message: tokenError.error_description || tokenError.error || 'Unlock failed',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
let workspacePreload: Promise<unknown> | null = null;
|
||||
let adminPreload: Promise<unknown> | null = null;
|
||||
let demoExperiencePreloadStarted = false;
|
||||
|
||||
export function preloadAuthenticatedWorkspace(isAdmin: boolean): Promise<unknown> {
|
||||
if (!workspacePreload) {
|
||||
workspacePreload = Promise.allSettled([
|
||||
import('@/components/SendsPage'),
|
||||
import('@/components/TotpCodesPage'),
|
||||
import('@/components/SettingsPage'),
|
||||
import('@/components/SecurityDevicesPage'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return workspacePreload;
|
||||
}
|
||||
|
||||
if (!adminPreload) {
|
||||
adminPreload = Promise.allSettled([
|
||||
workspacePreload,
|
||||
import('@/components/AdminPage'),
|
||||
import('@/components/BackupCenterPage'),
|
||||
]);
|
||||
}
|
||||
|
||||
return adminPreload;
|
||||
}
|
||||
|
||||
export function preloadDemoExperience(): () => void {
|
||||
if (demoExperiencePreloadStarted || typeof window === 'undefined') {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
demoExperiencePreloadStarted = true;
|
||||
let cancelled = false;
|
||||
let timerId: number | null = null;
|
||||
|
||||
const tasks = [
|
||||
() => import('@/components/VaultPage'),
|
||||
() => import('@/components/SendsPage'),
|
||||
() => import('@/components/TotpCodesPage'),
|
||||
() => import('@/components/SettingsPage'),
|
||||
() => import('@/components/SecurityDevicesPage'),
|
||||
() => import('@/components/AdminPage'),
|
||||
() => import('@/components/BackupCenterPage'),
|
||||
() => import('@/components/ImportPage'),
|
||||
];
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => {
|
||||
timerId = window.setTimeout(() => {
|
||||
timerId = null;
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
await wait(120);
|
||||
for (const task of tasks) {
|
||||
if (cancelled) return;
|
||||
await task().catch(() => undefined);
|
||||
await wait(180);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerId !== null) {
|
||||
window.clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { hkdf } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher, VaultDraft } from '@/lib/types';
|
||||
import type { VaultDraft } from '@/lib/types';
|
||||
import type { ImportResultSummary } from '@/components/ImportPage';
|
||||
|
||||
const SEND_KEY_SALT = 'bitwarden-send';
|
||||
@@ -26,11 +26,30 @@ export function looksLikeCipherString(value: string): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export function asText(value: unknown): string {
|
||||
function asText(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isImportTotpFieldName(value: unknown): boolean {
|
||||
const name = asText(value).trim().toLowerCase().replace(/[\s_-]+/g, '');
|
||||
return [
|
||||
'totp',
|
||||
'totpuri',
|
||||
'otp',
|
||||
'otpuri',
|
||||
'otpurl',
|
||||
'otpauth',
|
||||
'onetimepassword',
|
||||
'onetimepasscode',
|
||||
'2fa',
|
||||
'twofactor',
|
||||
'twofactorauthentication',
|
||||
'authenticator',
|
||||
'verificationcode',
|
||||
].includes(name);
|
||||
}
|
||||
|
||||
export function readInviteCodeFromUrl(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
@@ -87,7 +106,7 @@ export function summarizeImportResult(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmptyImportDraft(type: number): VaultDraft {
|
||||
function buildEmptyImportDraft(type: number): VaultDraft {
|
||||
return {
|
||||
type,
|
||||
favorite: false,
|
||||
@@ -162,6 +181,7 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
draft.loginPassword = asText(login.password);
|
||||
draft.loginTotp = asText(login.totp);
|
||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||
const seenUris = new Set<string>();
|
||||
const uris = urisRaw
|
||||
.map((u) => {
|
||||
const row = (u || {}) as Record<string, unknown>;
|
||||
@@ -176,8 +196,21 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((u) => !!u.uri);
|
||||
.filter((u) => {
|
||||
if (!u.uri) return false;
|
||||
const key = u.uri.toLowerCase();
|
||||
if (seenUris.has(key)) return false;
|
||||
seenUris.add(key);
|
||||
return true;
|
||||
});
|
||||
draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }];
|
||||
if (!draft.loginTotp) {
|
||||
const totpFieldIndex = draft.customFields.findIndex((field) => isImportTotpFieldName(field.label));
|
||||
if (totpFieldIndex >= 0) {
|
||||
draft.loginTotp = asText(draft.customFields[totpFieldIndex].value);
|
||||
draft.customFields = draft.customFields.filter((_, index) => index !== totpFieldIndex);
|
||||
}
|
||||
}
|
||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
: [];
|
||||
@@ -246,6 +279,3 @@ export async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{
|
||||
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
|
||||
}
|
||||
|
||||
export function findCipherById(ciphers: Cipher[], id: string): Cipher | null {
|
||||
return ciphers.find((cipher) => cipher.id === id) || null;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function detectBrowserTimeZone(): string {
|
||||
}
|
||||
|
||||
function createLocalizedDestinationName(type: BackupDestinationType, index: number): string {
|
||||
if (type === 'e3') return t('txt_backup_destination_name_default_e3', { index: String(index) });
|
||||
if (type === 's3') return t('txt_backup_destination_name_default_s3', { index: String(index) });
|
||||
return t('txt_backup_destination_name_default_webdav', { index: String(index) });
|
||||
}
|
||||
|
||||
@@ -207,6 +207,6 @@ export function getFirstVisibleDestinationId(settings: BackupSettings | null | u
|
||||
}
|
||||
|
||||
export function getDestinationTypeLabel(type: BackupDestinationType): string {
|
||||
if (type === 'e3') return t('txt_backup_protocol_e3');
|
||||
if (type === 's3') return t('txt_backup_protocol_s3');
|
||||
return t('txt_backup_protocol_webdav');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,49 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
return new Uint8Array(bytes).buffer;
|
||||
}
|
||||
|
||||
const hmacSha256KeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
|
||||
const aesCbcEncryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
|
||||
const aesCbcDecryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
|
||||
|
||||
function getCachedCryptoKey(
|
||||
cache: WeakMap<Uint8Array, Promise<CryptoKey>>,
|
||||
keyBytes: Uint8Array,
|
||||
create: () => Promise<CryptoKey>
|
||||
): Promise<CryptoKey> {
|
||||
const cached = cache.get(keyBytes);
|
||||
if (cached) return cached;
|
||||
const pending = create().catch((error) => {
|
||||
cache.delete(keyBytes);
|
||||
throw error;
|
||||
});
|
||||
cache.set(keyBytes, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return getCachedCryptoKey(
|
||||
hmacSha256KeyCache,
|
||||
keyBytes,
|
||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
||||
);
|
||||
}
|
||||
|
||||
function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return getCachedCryptoKey(
|
||||
aesCbcEncryptKeyCache,
|
||||
keyBytes,
|
||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
|
||||
);
|
||||
}
|
||||
|
||||
function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return getCachedCryptoKey(
|
||||
aesCbcDecryptKeyCache,
|
||||
keyBytes,
|
||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
|
||||
);
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
@@ -91,17 +134,17 @@ export async function hkdf(
|
||||
}
|
||||
|
||||
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const key = await getHmacSha256Key(keyBytes);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
|
||||
}
|
||||
|
||||
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['encrypt']);
|
||||
const cryptoKey = await getAesCbcEncryptKey(key);
|
||||
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||
}
|
||||
|
||||
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['decrypt']);
|
||||
const cryptoKey = await getAesCbcDecryptKey(key);
|
||||
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { decryptStr, decryptBw } from './crypto';
|
||||
import type { Cipher } from './types';
|
||||
|
||||
async function decryptField(
|
||||
value: string | null | undefined,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array,
|
||||
): Promise<string> {
|
||||
if (!value || typeof value !== 'string') return '';
|
||||
try { return await decryptStr(value, enc, mac); } catch { return value; }
|
||||
}
|
||||
|
||||
export async function decryptSingleCipher(
|
||||
encrypted: Cipher,
|
||||
userEnc: Uint8Array,
|
||||
userMac: Uint8Array,
|
||||
): Promise<Cipher> {
|
||||
let itemEnc = userEnc;
|
||||
let itemMac = userMac;
|
||||
if (encrypted.key) {
|
||||
try {
|
||||
const itemKey = await decryptBw(encrypted.key, userEnc, userMac);
|
||||
itemEnc = itemKey.slice(0, 32);
|
||||
itemMac = itemKey.slice(32, 64);
|
||||
} catch { /* keep user key */ }
|
||||
}
|
||||
|
||||
const decrypted: Cipher = {
|
||||
...encrypted,
|
||||
decName: await decryptField(encrypted.name, itemEnc, itemMac),
|
||||
decNotes: await decryptField(encrypted.notes, itemEnc, itemMac),
|
||||
};
|
||||
|
||||
if (encrypted.login) {
|
||||
decrypted.login = {
|
||||
...encrypted.login,
|
||||
decUsername: await decryptField(encrypted.login.username, itemEnc, itemMac),
|
||||
decPassword: await decryptField(encrypted.login.password, itemEnc, itemMac),
|
||||
decTotp: await decryptField(encrypted.login.totp, itemEnc, itemMac),
|
||||
uris: await Promise.all((encrypted.login.uris || []).map(async (u) => ({
|
||||
...u,
|
||||
decUri: await decryptField(u.uri, itemEnc, itemMac),
|
||||
}))),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(encrypted.passwordHistory)) {
|
||||
decrypted.passwordHistory = await Promise.all(
|
||||
encrypted.passwordHistory.map(async (entry) => ({
|
||||
...entry,
|
||||
decPassword: await decryptField(entry?.password, itemEnc, itemMac),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (encrypted.card) {
|
||||
decrypted.card = {
|
||||
...encrypted.card,
|
||||
decCardholderName: await decryptField(encrypted.card.cardholderName, itemEnc, itemMac),
|
||||
decNumber: await decryptField(encrypted.card.number, itemEnc, itemMac),
|
||||
decBrand: await decryptField(encrypted.card.brand, itemEnc, itemMac),
|
||||
decExpMonth: await decryptField(encrypted.card.expMonth, itemEnc, itemMac),
|
||||
decExpYear: await decryptField(encrypted.card.expYear, itemEnc, itemMac),
|
||||
decCode: await decryptField(encrypted.card.code, itemEnc, itemMac),
|
||||
};
|
||||
}
|
||||
|
||||
if (encrypted.identity) {
|
||||
decrypted.identity = {
|
||||
...encrypted.identity,
|
||||
decTitle: await decryptField(encrypted.identity.title, itemEnc, itemMac),
|
||||
decFirstName: await decryptField(encrypted.identity.firstName, itemEnc, itemMac),
|
||||
decMiddleName: await decryptField(encrypted.identity.middleName, itemEnc, itemMac),
|
||||
decLastName: await decryptField(encrypted.identity.lastName, itemEnc, itemMac),
|
||||
decUsername: await decryptField(encrypted.identity.username, itemEnc, itemMac),
|
||||
decCompany: await decryptField(encrypted.identity.company, itemEnc, itemMac),
|
||||
decSsn: await decryptField(encrypted.identity.ssn, itemEnc, itemMac),
|
||||
decPassportNumber: await decryptField(encrypted.identity.passportNumber, itemEnc, itemMac),
|
||||
decLicenseNumber: await decryptField(encrypted.identity.licenseNumber, itemEnc, itemMac),
|
||||
decEmail: await decryptField(encrypted.identity.email, itemEnc, itemMac),
|
||||
decPhone: await decryptField(encrypted.identity.phone, itemEnc, itemMac),
|
||||
decAddress1: await decryptField(encrypted.identity.address1, itemEnc, itemMac),
|
||||
decAddress2: await decryptField(encrypted.identity.address2, itemEnc, itemMac),
|
||||
decAddress3: await decryptField(encrypted.identity.address3, itemEnc, itemMac),
|
||||
decCity: await decryptField(encrypted.identity.city, itemEnc, itemMac),
|
||||
decState: await decryptField(encrypted.identity.state, itemEnc, itemMac),
|
||||
decPostalCode: await decryptField(encrypted.identity.postalCode, itemEnc, itemMac),
|
||||
decCountry: await decryptField(encrypted.identity.country, itemEnc, itemMac),
|
||||
};
|
||||
}
|
||||
|
||||
if (encrypted.sshKey) {
|
||||
const fingerprint = encrypted.sshKey.keyFingerprint || encrypted.sshKey.fingerprint || '';
|
||||
decrypted.sshKey = {
|
||||
...encrypted.sshKey,
|
||||
decPrivateKey: await decryptField(encrypted.sshKey.privateKey, itemEnc, itemMac),
|
||||
decPublicKey: await decryptField(encrypted.sshKey.publicKey, itemEnc, itemMac),
|
||||
keyFingerprint: fingerprint || null,
|
||||
fingerprint: fingerprint || null,
|
||||
decFingerprint: await decryptField(fingerprint, itemEnc, itemMac),
|
||||
};
|
||||
}
|
||||
|
||||
if (encrypted.fields) {
|
||||
decrypted.fields = await Promise.all(
|
||||
encrypted.fields.map(async (field) => ({
|
||||
...field,
|
||||
decName: await decryptField(field.name, itemEnc, itemMac),
|
||||
decValue: await decryptField(field.value, itemEnc, itemMac),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import type { CompletedLogin, InitialAppBootstrapState } from '@/lib/app-auth';
|
||||
import type { AdminBackupSettings } from '@/lib/api/backup';
|
||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder, Send } from '@/lib/types';
|
||||
|
||||
export const IS_DEMO_MODE = false;
|
||||
|
||||
export const DEMO_CIPHERS: Cipher[] = [];
|
||||
export const DEMO_ADMIN_INVITES: AdminInvite[] = [];
|
||||
export const DEMO_ADMIN_USERS: AdminUser[] = [];
|
||||
export const DEMO_AUTHORIZED_DEVICES: AuthorizedDevice[] = [];
|
||||
export const DEMO_FOLDERS: Folder[] = [];
|
||||
export const DEMO_SENDS: Send[] = [];
|
||||
|
||||
export function createDemoBackupSettings(): AdminBackupSettings {
|
||||
return { destinations: [] };
|
||||
}
|
||||
|
||||
export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
||||
return {
|
||||
defaultKdfIterations: 600000,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
phase: 'login',
|
||||
};
|
||||
}
|
||||
|
||||
export function createDemoCompletedLogin(): CompletedLogin {
|
||||
throw new Error('Demo mode is not available in this build.');
|
||||
}
|
||||
|
||||
export function createDemoMainRoutesProps(base: AppMainRoutesProps): AppMainRoutesProps {
|
||||
return base;
|
||||
}
|
||||
|
||||
export function getDemoPublicSend(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function demoBrandIconUrl(_host: string): string {
|
||||
return '';
|
||||
}
|
||||
@@ -380,85 +380,6 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
|
||||
return JSON.stringify(doc, null, 2);
|
||||
}
|
||||
|
||||
export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise<string> {
|
||||
const doc = await buildPlainBitwardenJsonDocument(args);
|
||||
const folders = Array.isArray(doc.folders) ? (doc.folders as Array<Record<string, unknown>>) : [];
|
||||
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
|
||||
|
||||
const folderNameById = new Map<string, string>();
|
||||
for (const folder of folders) {
|
||||
const id = normalizeString(folder.id);
|
||||
if (!id) continue;
|
||||
folderNameById.set(id, normalizeString(folder.name) || '');
|
||||
}
|
||||
|
||||
const header = [
|
||||
'folder',
|
||||
'favorite',
|
||||
'type',
|
||||
'name',
|
||||
'notes',
|
||||
'fields',
|
||||
'reprompt',
|
||||
'archivedDate',
|
||||
'login_uri',
|
||||
'login_username',
|
||||
'login_password',
|
||||
'login_totp',
|
||||
];
|
||||
|
||||
const rows: string[][] = [header];
|
||||
for (const item of items) {
|
||||
const type = normalizeNumber(item.type, 1);
|
||||
if (type !== 1 && type !== 2) continue;
|
||||
const folderId = normalizeString(item.folderId);
|
||||
const folderName = folderId ? folderNameById.get(folderId) || '' : '';
|
||||
const fields = Array.isArray(item.fields)
|
||||
? (item.fields as Array<Record<string, unknown>>)
|
||||
.map((field) => {
|
||||
const name = normalizeString(field.name) || '';
|
||||
const value = normalizeString(field.value) || '';
|
||||
if (!name && !value) return '';
|
||||
return `${name}: ${value}`;
|
||||
})
|
||||
.filter((line) => !!line)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
const login = isRecord(item.login) ? (item.login as Record<string, unknown>) : null;
|
||||
const loginUris = login && Array.isArray(login.uris)
|
||||
? (login.uris as Array<Record<string, unknown>>)
|
||||
.map((uri) => normalizeString(uri.uri) || '')
|
||||
.filter((uri) => !!uri)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
rows.push([
|
||||
folderName,
|
||||
item.favorite ? '1' : '',
|
||||
type === 1 ? 'login' : 'note',
|
||||
normalizeString(item.name) || '',
|
||||
normalizeString(item.notes) || '',
|
||||
fields,
|
||||
String(normalizeNumber(item.reprompt, 0)),
|
||||
normalizeString(item.archivedDate) || '',
|
||||
loginUris,
|
||||
normalizeString(login?.username) || '',
|
||||
normalizeString(login?.password) || '',
|
||||
normalizeString(login?.totp) || '',
|
||||
]);
|
||||
}
|
||||
|
||||
const escapeCsv = (value: string): string => {
|
||||
if (/[",\n\r]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return rows.map((row) => row.map((cell) => escapeCsv(String(cell || ''))).join(',')).join('\n');
|
||||
}
|
||||
|
||||
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||
const userEnc = base64ToBytes(args.userEncB64);
|
||||
const userMac = base64ToBytes(args.userMacB64);
|
||||
|
||||