37 Commits

Author SHA1 Message Date
52assert 667afa305b fix(deploy): make KV deploy idempotent
Adapted from #233 with deploy build kept in wrangler config.
2026-05-31 01:20:14 +08:00
shuaiplus 85bd2fa4bf fix: streamline deployment commands in configuration files 2026-05-31 01:15:00 +08:00
shuaiplus fd9707c396 fix: enable cipher key encryption feature for 2026.4.x clients and streamline key handling 2026-05-31 01:03:32 +08:00
shuaiplus 192071e4a7 fix: enhance cipher key handling and compatibility for secure notes 2026-05-30 02:43:09 +08:00
shuaiplus fcf7c80daa fix: adjust input padding for improved layout in forms and responsive styles 2026-05-30 02:34:45 +08:00
shuaiplus ed9251c014 fix: enhance compatibility for cipher login normalization and uri handling 2026-05-30 02:26:36 +08:00
shuaiplus a75955ca6d fix: update password verification to support legacy client hashes 2026-05-23 23:07:10 +08:00
shuaiplus 03f7fbf601 fix: repair mixed cipher key encryption handling 2026-05-23 12:43:44 +08:00
shuaiplus a63336764f fix: improve lock timeout retrieval by handling null and empty values 2026-05-23 03:19:49 +08:00
shuaiplus f56d7f01ca fix: add content length validation and timeout handling for icon fetching 2026-05-23 03:17:24 +08:00
shuaiplus 8ff60aed24 fix: remove unused change password handling functions from public route 2026-05-23 03:08:21 +08:00
shuaiplus 749de4e2e1 fix: update server hash prefix handling for password hashing and verification 2026-05-23 03:00:58 +08:00
shuaiplus ea9e238aa7 fix: remove checks for portable admins in backup settings saving and normalization 2026-05-23 02:53:03 +08:00
shuaiplus 22d267f5bc fix: remove unused saveRefreshTokenRecord parameter from getRefreshTokenRecord 2026-05-23 02:42:08 +08:00
shuaiplus 18eefd1174 fix: simplify login identifier construction in two-factor recovery and token handling 2026-05-23 02:22:04 +08:00
shuaiplus d468745841 fix: restore ip-scoped password login lockout 2026-05-23 02:12:40 +08:00
shuaiplus 970621c459 fix: remove optional TOTP_SECRET from environment bindings 2026-05-23 02:07:59 +08:00
shuaiplus 385a873e65 fix: improve device validation logic in refresh token handling 2026-05-23 02:00:41 +08:00
shuaiplus 56185ecb69 fix: strip plaintext login helpers from cipher payload 2026-05-23 01:49:34 +08:00
shuaiplus 04ebfc7021 feat: refactor cipher login data type for improved clarity 2026-05-18 02:13:01 +08:00
shuaiplus c50247b8fe feat: add URI checksum repair functionality for ciphers 2026-05-18 01:59:02 +08:00
shuaiplus 776408e9d0 feat: enhance SSH key handling with Ed25519 support and PEM formatting 2026-05-16 16:34:06 +08:00
shuaiplus e641da517d feat: add uriChecksum handling and sha256Base64 function for enhanced security 2026-05-16 16:22:43 +08:00
shuaiplus b7878ffe01 feat: improve scrollbar styles and dark mode compatibility 2026-05-15 19:12:40 +08:00
shuaiplus bbad9d60a7 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-05-15 18:28:09 +08:00
shuaiplus ed58467766 feat: enhance authorized devices table layout and styling 2026-05-15 18:28:05 +08:00
agesky 2f911e66a6 Update README.md
修改一处描述错误
2026-05-15 11:12:47 +08:00
shuaiplus d06e050162 feat: Updated visual rapid deployment instructions, added JWT_SECRET settings and Workers custom domain prompts 2026-05-14 22:54:54 +08:00
shuaiplus d0dc31ce86 feat: enhance attachment metadata handling and add change password URI support 2026-05-14 22:46:29 +08:00
shuaiplus f64abaa75d feat: enhance search functionality by including cipher ID in search text 2026-05-14 10:52:11 +08:00
shuaiplus 7312086f92 feat: add restore functionality for deleted items with corresponding UI updates 2026-05-14 10:40:32 +08:00
shuaiplus 3e4c104e1d feat: added logging system 2026-05-14 02:42:15 +08:00
shuaiplus 17ceec45b1 feat: implement user and device cache invalidation in AuthService 2026-05-12 19:12:53 +08:00
shuaiplus 2685741386 feat: add permanent trust functionality for devices with corresponding API and UI updates 2026-05-12 18:01:04 +08:00
shuaiplus 83a1fc2376 feat: enhance TOTP settings UI with improved layout and status indication 2026-05-12 15:55:05 +08:00
shuaiplus 06431c4145 feat: enhance mobile responsiveness for management routes and table layout 2026-05-12 15:16:17 +08:00
shuaiplus 700910099b feat: adjust eye button positioning and hover effect for password toggle 2026-05-12 00:22:48 +08:00
65 changed files with 4927 additions and 355 deletions
+2 -1
View File
@@ -26,7 +26,7 @@ Thumbs.db
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
.vite-tailwind.err
# Environment # Environment
.env .env
.env.local .env.local
@@ -47,3 +47,4 @@ AGENTS.md
settings.json settings.json
.claude/ .claude/
NodeWarden-compat/ NodeWarden-compat/
.codex-upstream/
+15 -10
View File
@@ -38,7 +38,7 @@
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send | | Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | | 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** | | **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** | | 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 | | TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 | | 多用户 | ✅ | ✅ | 支持邀请码注册 |
@@ -58,16 +58,21 @@
--- ---
## 网页部署 ## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 `Continue with GitHub`
4. 选择你刚刚 Fork 的仓库
5. 保持默认配置继续部署
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
7. 等部署完成后,打开生成的 Workers 域名
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
> [!TIP] > [!TIP]
> 默认R2与可选KV的区别: > 默认R2与可选KV的区别:
+4
View File
@@ -154,6 +154,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
actor_user_id TEXT, actor_user_id TEXT,
action TEXT NOT NULL, action TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'system',
level TEXT NOT NULL DEFAULT 'info',
target_type TEXT, target_type TEXT,
target_id TEXT, target_id TEXT,
metadata TEXT, metadata TEXT,
@@ -162,6 +164,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
); );
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at); CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
CREATE TABLE IF NOT EXISTS devices ( CREATE TABLE IF NOT EXISTS devices (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
+2 -2
View File
@@ -15,8 +15,8 @@
"domains:sync": "node scripts/sync-global-domains.mjs", "domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs", "i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "npm run build && wrangler deploy", "deploy": "wrangler deploy",
"deploy:kv": "npm run build && wrangler deploy -c wrangler.kv.toml", "deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo" "deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
}, },
"keywords": [ "keywords": [
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Make `deploy:kv` idempotent across repeated builds.
*
* KV namespaces are referenced in wrangler config by account-scoped `id`, not
* by name. The template ships without an id so fresh accounts can provision one
* on first deploy. In non-interactive builds, wrangler may try to create the
* same namespace again on later builds and fail with code 10014.
*/
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const CONFIG = path.resolve(__dirname, '..', 'wrangler.kv.toml');
const BINDING = 'ATTACHMENTS_KV';
const wrangler = (args) =>
execSync(`npx wrangler ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
function bindingBlockHasId(toml) {
const blocks = toml.match(/\[\[kv_namespaces\]\][^[]*/g) || [];
const block = blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${BINDING}"`).test(entry));
return block ? /^\s*id\s*=/m.test(block) : false;
}
function expectedTitle(toml) {
const name = (toml.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1] || 'worker';
return `${name}-${BINDING.toLowerCase().replace(/_/g, '-')}`;
}
function resolveId(title) {
const list = JSON.parse(wrangler('kv namespace list'));
const hit =
list.find((namespace) => namespace.title === title) ||
list.find((namespace) => typeof namespace.title === 'string' && namespace.title.endsWith('attachments-kv'));
if (hit) {
console.log(`[ensure-kv] reusing existing namespace "${hit.title}" (${hit.id})`);
return hit.id;
}
const out = wrangler(`kv namespace create "${title}"`);
const id = (out.match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1];
if (!id) throw new Error(`[ensure-kv] could not parse new namespace id from:\n${out}`);
console.log(`[ensure-kv] created namespace "${title}" (${id})`);
return id;
}
function main() {
let toml = fs.readFileSync(CONFIG, 'utf8');
if (bindingBlockHasId(toml)) {
console.log(`[ensure-kv] ${BINDING} already pinned in wrangler.kv.toml; nothing to do`);
return;
}
const id = resolveId(expectedTitle(toml));
toml = toml.replace(
new RegExp(`(\\[\\[kv_namespaces\\]\\]\\s*\\n\\s*binding\\s*=\\s*"${BINDING}")`),
`$1\nid = "${id}"`
);
fs.writeFileSync(CONFIG, toml);
console.log('[ensure-kv] pinned id into wrangler.kv.toml for this build');
}
main();
+12 -1
View File
@@ -22,6 +22,17 @@ const intentionallyEnglishKeys = new Set([
'txt_dash', 'txt_dash',
'txt_text_3', 'txt_text_3',
]); ]);
const intentionallyEnglishPrefixes = [
'txt_log_action_',
'txt_log_meta_',
'txt_log_reason_',
'txt_log_target_type_',
'txt_log_trigger_',
];
function isIntentionallyEnglishKey(key) {
return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix));
}
for (const [locale, table] of Object.entries(locales)) { for (const [locale, table] of Object.entries(locales)) {
const keys = Object.keys(table).sort(); const keys = Object.keys(table).sort();
@@ -40,7 +51,7 @@ for (const [locale, table] of Object.entries(locales)) {
} }
if (locale !== 'en') { if (locale !== 'en') {
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !intentionallyEnglishKeys.has(key)); const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key));
if (sameAsEnglish.length > 40) { if (sameAsEnglish.length > 40) {
errors.push({ errors.push({
locale, locale,
+11 -3
View File
@@ -5,10 +5,10 @@
accessTokenTtlSeconds: 7200, accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds. // Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。 // 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms). // Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 // 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 60 * 1000, refreshTokenOverlapGraceMs: 30 * 60 * 1000,
// Refresh token random byte length. // Refresh token random byte length.
// 刷新令牌随机字节长度。 // 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32, refreshTokenRandomBytes: 32,
@@ -44,6 +44,9 @@
// Public read-only request budget per IP per minute. // Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。 // 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120, publicReadRequestsPerMinute: 120,
// Public website icon proxy budget per IP per minute.
// 公开网站图标代理每 IP 每分钟请求配额。
publicIconRequestsPerMinute: 500,
// Sensitive public/auth request budget per IP per minute. // Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。 // 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30, sensitivePublicRequestsPerMinute: 30,
@@ -145,6 +148,11 @@
compatibility: { compatibility: {
// Single source of truth for /config.version and /api/version. // Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。 // /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2026.1.0', bitwardenServerVersion: '2026.4.1',
// Official 2026.4.x clients need this flag to receive and use cipher.key.
// Hiding existing item keys makes item-key encrypted vault data unreadable.
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
cipherKeyEncryptionFeatureEnabled: true,
}, },
} as const; } as const;
+93 -13
View File
@@ -2,6 +2,7 @@ import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../services/audit-events';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
@@ -227,14 +228,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Registration is temporarily unavailable, retry once', 409); return errorResponse('Registration is temporarily unavailable, retry once', 409);
} }
await storage.setRegistered(); await storage.setRegistered();
await storage.createAuditLog({ await writeAuditEvent(storage, {
id: generateUUID(),
actorUserId: user.id, actorUserId: user.id,
action: 'user.register.first_admin', action: 'user.register.first_admin',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: JSON.stringify({ email: user.email }), category: 'security',
createdAt: now, level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
}); });
return jsonResponse({ success: true, role: user.role }, 200); return jsonResponse({ success: true, role: user.role }, 200);
} }
@@ -259,14 +260,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is invalid or expired', 403); return errorResponse('Invite code is invalid or expired', 403);
} }
await storage.createAuditLog({ await writeAuditEvent(storage, {
id: generateUUID(),
actorUserId: user.id, actorUserId: user.id,
action: 'user.register.invite', action: 'user.register.invite',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: JSON.stringify({ email: user.email, inviteCode }), category: 'security',
createdAt: now, level: 'info',
metadata: { email: user.email, inviteCode, ...auditRequestMetadata(request) },
}); });
return jsonResponse({ success: true, role: user.role }, 200); return jsonResponse({ success: true, role: user.role }, 200);
@@ -378,6 +379,18 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
user.masterPasswordHint = masterPasswordHint; user.masterPasswordHint = masterPasswordHint;
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.profile.update',
category: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
updatedMasterPasswordHint: true,
...auditRequestMetadata(request),
},
});
return jsonResponse(toProfile(user, env)); return jsonResponse(toProfile(user, env));
} }
@@ -412,6 +425,18 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
user.verifyDevices = body.verifyDevices; user.verifyDevices = body.verifyDevices;
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
@@ -461,6 +486,20 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.keys.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
updatedKey: !!body.key,
updatedPrivateKey: !!body.encryptedPrivateKey,
updatedPublicKey: !!body.publicKey,
...auditRequestMetadata(request),
},
});
return handleGetProfile(request, env, userId); return handleGetProfile(request, env, userId);
} }
@@ -526,14 +565,15 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
await storage.createAuditLog({ AuthService.invalidateUserCache(user.id);
id: generateUUID(), await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'user.password.change', action: 'user.password.change',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: JSON.stringify({ email: user.email }), category: 'security',
createdAt: user.updatedAt, level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
}); });
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
@@ -587,6 +627,16 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' }); return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
} }
@@ -601,6 +651,16 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.disable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ enabled: false, object: 'twoFactor' }); return jsonResponse({ enabled: false, object: 'twoFactor' });
} }
@@ -671,7 +731,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
if (!clientIdentifier) { if (!clientIdentifier) {
return errorResponse('Client IP is required', 403); return errorResponse('Client IP is required', 403);
} }
const recoverLimitKey = `${clientIdentifier}:recover-2fa:${email || 'unknown'}`; const recoverLimitKey = `${clientIdentifier}:recover-2fa`;
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey); const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) { if (!recoverAttemptCheck.allowed) {
@@ -708,7 +768,17 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey); await rateLimit.clearLoginAttempts(recoverLimitKey);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'account.totp.recover',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ return jsonResponse({
success: true, success: true,
@@ -801,6 +871,16 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
} }
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
category: 'security',
level: rotate ? 'security' : 'info',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
} }
return jsonResponse({ return jsonResponse({
+120 -13
View File
@@ -1,8 +1,9 @@
import { Env, User, Invite } from '../types'; import { Env, User, Invite } from '../types';
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store'; import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
function isAdmin(user: User): boolean { function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
@@ -24,16 +25,20 @@ async function writeAuditLog(
action: string, action: string,
targetType: string | null, targetType: string | null,
targetId: string | null, targetId: string | null,
metadata: Record<string, unknown> | null metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> { ): Promise<void> {
await storage.createAuditLog({ await writeAuditEvent(storage, {
id: generateUUID(),
actorUserId, actorUserId,
action, action,
targetType, targetType,
targetId, targetId,
metadata: metadata ? JSON.stringify(metadata) : null, category: action.startsWith('admin.user.') ? 'security' : 'system',
createdAt: new Date().toISOString(), level: action.startsWith('admin.user.') ? 'security' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
}); });
} }
@@ -81,6 +86,106 @@ export async function handleAdminListUsers(
}); });
} }
// GET /api/admin/logs
export async function handleAdminListAuditLogs(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const url = new URL(request.url);
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
const category = String(url.searchParams.get('category') || '').trim() || null;
const level = String(url.searchParams.get('level') || '').trim() || null;
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
const from = String(url.searchParams.get('from') || '').trim() || null;
const to = String(url.searchParams.get('to') || '').trim() || null;
const storage = new StorageService(env.DB);
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
return jsonResponse({
data: result.logs.map(log => ({
id: log.id,
actorUserId: log.actorUserId,
actorEmail: log.actorEmail,
action: log.action,
category: log.category,
level: log.level,
targetType: log.targetType,
targetId: log.targetId,
targetUserEmail: log.targetUserEmail,
metadata: log.metadata,
createdAt: log.createdAt,
object: 'auditLog',
})),
total: result.total,
limit,
offset,
hasMore: result.hasMore,
object: 'list',
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
});
}
// GET /api/admin/logs/settings
export async function handleAdminGetAuditLogSettings(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
return jsonResponse({
object: 'auditLogSettings',
...await getAuditLogSettings(storage),
});
}
// PUT /api/admin/logs/settings
export async function handleAdminUpdateAuditLogSettings(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const storage = new StorageService(env.DB);
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
return jsonResponse({
object: 'auditLogSettings',
...settings,
});
}
// DELETE /api/admin/logs
export async function handleAdminClearAuditLogs(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const deleted = await storage.clearAuditLogs();
return jsonResponse({ object: 'auditLogClear', deleted });
}
// POST /api/admin/invites // POST /api/admin/invites
export async function handleAdminCreateInvite( export async function handleAdminCreateInvite(
request: Request, request: Request,
@@ -115,9 +220,9 @@ export async function handleAdminCreateInvite(
}; };
await storage.createInvite(invite); await storage.createInvite(invite);
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, { await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
expiresInHours, expiresInHours,
}); }, request);
return jsonResponse(toInviteResponse(request, invite), 201); return jsonResponse(toInviteResponse(request, invite), 201);
} }
@@ -160,7 +265,7 @@ export async function handleAdminRevokeInvite(
return errorResponse('Invite not found or already inactive', 404); return errorResponse('Invite not found or already inactive', 404);
} }
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null); await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -179,7 +284,7 @@ export async function handleAdminDeleteAllInvites(
const deleted = await storage.deleteAllInvites(); const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, { await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted, deleted,
}); }, request);
return jsonResponse({ deleted }, 200); return jsonResponse({ deleted }, 200);
} }
@@ -222,9 +327,10 @@ export async function handleAdminSetUserStatus(
if (nextStatus === 'banned') { if (nextStatus === 'banned') {
await storage.deleteRefreshTokensByUserId(target.id); await storage.deleteRefreshTokensByUserId(target.id);
} }
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, { await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus, status: nextStatus,
}); }, request);
return jsonResponse({ return jsonResponse({
id: target.id, id: target.id,
@@ -280,9 +386,10 @@ export async function handleAdminDeleteUser(
await storage.deleteRefreshTokensByUserId(target.id); await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id); await storage.deleteUserById(target.id);
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, { await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
email: target.email, targetEmail: target.email,
}); }, request);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
+34 -6
View File
@@ -10,7 +10,7 @@ import {
verifyAttachmentUploadToken, verifyAttachmentUploadToken,
verifyFileDownloadToken, verifyFileDownloadToken,
} from '../utils/jwt'; } from '../utils/jwt';
import { cipherToResponse } from './ciphers'; import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { import {
@@ -20,6 +20,7 @@ import {
getBlobStorageMaxBytes, getBlobStorageMaxBytes,
putBlobObject, putBlobObject,
} from '../services/blob-store'; } from '../services/blob-store';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
@@ -30,6 +31,27 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
async function writeAttachmentAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'attachment',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Format file size to human readable // Format file size to human readable
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`; if (bytes < 1024) return `${bytes} Bytes`;
@@ -260,6 +282,7 @@ export async function handleGetAttachment(
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
// Generate short-lived download token // Generate short-lived download token
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET); const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
@@ -270,12 +293,12 @@ export async function handleGetAttachment(
return jsonResponse({ return jsonResponse({
object: 'attachment', object: 'attachment',
id: attachment.id, id: responseAttachment.id,
url: downloadUrl, url: downloadUrl,
fileName: attachment.fileName, fileName: responseAttachment.fileName,
key: attachment.key, key: responseAttachment.key,
size: String(Number(attachment.size) || 0), size: String(Number(responseAttachment.size) || 0),
sizeName: attachment.sizeName, sizeName: responseAttachment.sizeName,
}); });
} }
@@ -430,6 +453,11 @@ export async function handleDeleteAttachment(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
id: attachmentId,
cipherId,
size: attachment.size,
});
} }
// Get updated cipher for response // Get updated cipher for response
+22 -13
View File
@@ -40,6 +40,7 @@ import {
uploadBackupArchive, uploadBackupArchive,
} from '../services/backup-uploader'; } from '../services/backup-uploader';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store'; import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
@@ -53,16 +54,20 @@ async function writeAuditLog(
action: string, action: string,
targetType: string | null, targetType: string | null,
targetId: string | null, targetId: string | null,
metadata: Record<string, unknown> | null metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> { ): Promise<void> {
await storage.createAuditLog({ await writeAuditEvent(storage, {
id: generateUUID(),
actorUserId, actorUserId,
action, action,
targetType, targetType,
targetId, targetId,
metadata: metadata ? JSON.stringify(metadata) : null, category: 'data',
createdAt: new Date().toISOString(), level: action.endsWith('.failed') ? 'error' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
}); });
} }
@@ -267,7 +272,8 @@ async function executeConfiguredBackup(
done?: boolean; done?: boolean;
ok?: boolean; ok?: boolean;
error?: string | null; error?: string | null;
}) => Promise<void>) | null }) => Promise<void>) | null,
auditMetadata?: Record<string, unknown> | null
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> { ): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const maxArchiveUploadAttempts = 3; const maxArchiveUploadAttempts = 3;
const touchLease = async () => { const touchLease = async () => {
@@ -423,6 +429,7 @@ async function executeConfiguredBackup(
uploadVerificationAttempts: maxArchiveUploadAttempts, uploadVerificationAttempts: maxArchiveUploadAttempts,
prunedFileCount, prunedFileCount,
pruneError: pruneErrorMessage, pruneError: pruneErrorMessage,
...(auditMetadata || {}),
}); });
await progress?.({ await progress?.({
@@ -451,6 +458,7 @@ async function executeConfiguredBackup(
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage, error: destination.runtime.lastErrorMessage,
...(auditMetadata || {}),
}); });
await progress?.({ await progress?.({
operation: 'backup-remote-run', operation: 'backup-remote-run',
@@ -513,7 +521,7 @@ async function runImportAndAudit(
skippedReason: imported.result.skipped.reason, skippedReason: imported.result.skipped.reason,
replaceExisting, replaceExisting,
...metadata, ...metadata,
}); }, request);
return imported; return imported;
} }
@@ -586,7 +594,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, { await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
destinationCount: next.destinations.length, destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}); }, request);
return jsonResponse(next); return jsonResponse(next);
} }
@@ -636,7 +644,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, { await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
destinationCount: next.destinations.length, destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}); }, request);
return jsonResponse(next); return jsonResponse(next);
} }
@@ -675,7 +683,8 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
'manual', 'manual',
body?.destinationId || null, body?.destinationId || null,
keepAlive, keepAlive,
progress progress,
auditRequestMetadata(request)
); );
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
return { result, settings }; return { result, settings };
@@ -777,7 +786,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, { await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
remotePath: path, remotePath: path,
}); }, request);
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path }); return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
} catch (error) { } catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409); return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
@@ -860,7 +869,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
bytes: remoteFile.bytes.byteLength, bytes: remoteFile.bytes.byteLength,
trigger: 'remote', trigger: 'remote',
checksumMismatchAccepted: !checksumOk, checksumMismatchAccepted: !checksumOk,
}); }, request);
return result; return result;
})(); })();
return jsonResponse(imported.result); return jsonResponse(imported.result);
@@ -937,7 +946,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
attachments: archive.manifest.tableCounts.attachments, attachments: archive.manifest.tableCounts.attachments,
compressedBytes: archive.bytes.byteLength, compressedBytes: archive.bytes.byteLength,
includesAttachments: archive.manifest.includes.attachments, includesAttachments: archive.manifest.includes.attachments,
}); }, request);
return new Response(archive.bytes, { return new Response(archive.bytes, {
status: 200, status: 200,
+337 -12
View File
@@ -17,6 +17,7 @@ import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments'; import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
// CONTRACT: // CONTRACT:
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve // Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
@@ -83,6 +84,27 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
return cipher; return cipher;
} }
async function writeCipherAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'cipher',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
function isValidEncString(value: unknown): value is string { function isValidEncString(value: unknown): value is string {
if (typeof value !== 'string') return false; if (typeof value !== 'string') return false;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -107,6 +129,14 @@ function optionalEncString(value: unknown): string | null {
return isValidEncString(value) ? value.trim() : null; return isValidEncString(value) ? value.trim() : null;
} }
function shouldAcceptCipherKey(value: unknown): boolean {
return value == null || value === '' || isValidEncString(value);
}
function normalizeCipherKeyForStorage(value: unknown): string | null {
return optionalEncString(value);
}
function sanitizeEncryptedObject<T extends Record<string, any>>( function sanitizeEncryptedObject<T extends Record<string, any>>(
source: T | null | undefined, source: T | null | undefined,
encryptedKeys: readonly string[] encryptedKeys: readonly string[]
@@ -139,20 +169,67 @@ export function normalizeCipherLoginForStorage(login: any): any {
}; };
} }
export function normalizeCipherLoginForCompatibility(login: any): any { export function normalizeCipherLoginForCompatibility(login: any, requiresUriChecksum: boolean = false): any {
const normalized = normalizeCipherLoginForStorage(login); const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null; if (!normalized || typeof normalized !== 'object') return normalized ?? null;
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']); const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
if (!next) return null; if (!next) return null;
next.uris = Array.isArray(next.uris) next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
? next.uris hasLegacyLoginUri: isValidEncString(next.uri),
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum'])) requiresUriChecksum,
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null)) });
: null;
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials); next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
return next; return next;
} }
function normalizeCipherLoginUrisForCompatibility(
uris: any,
options: { hasLegacyLoginUri?: boolean; requiresUriChecksum?: boolean } = {}
): any[] | null {
if (!Array.isArray(uris) || uris.length === 0) return null;
const out: any[] = [];
for (const uri of uris) {
if (!uri || typeof uri !== 'object') continue;
const next = sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']);
if (!next) continue;
const hasUri = isValidEncString(next.uri);
const hasChecksum = isValidEncString(next.uriChecksum);
const hasMatch = next.match != null;
if (hasUri && hasChecksum) {
out.push(next);
continue;
}
if (hasUri && !hasChecksum) {
// Bitwarden browser clients using the SDK can fail the whole vault load
// when an item-key encrypted URI has no encrypted checksum. The server
// cannot derive the checksum, so expose the item without the bad URI.
if (options.requiresUriChecksum || options.hasLegacyLoginUri) continue;
out.push({ ...next, uri: null, uriChecksum: null });
continue;
}
if (hasChecksum || hasMatch) {
out.push(next);
}
}
return out.length ? out : null;
}
function hasMissingLoginUriChecksum(cipher: Cipher): boolean {
if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false;
const uris = (cipher.login as any).uris;
if (!Array.isArray(uris)) return false;
return uris.some((uri: any) => {
if (!uri || typeof uri !== 'object') return false;
return isValidEncString(uri.uri) && !isValidEncString(uri.uriChecksum);
});
}
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null { function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
if (!Array.isArray(credentials) || credentials.length === 0) return null; if (!Array.isArray(credentials) || credentials.length === 0) return null;
const requiredEncryptedKeys = [ const requiredEncryptedKeys = [
@@ -223,6 +300,14 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
}; };
} }
function normalizeCipherSecureNoteForCompatibility(secureNote: any): CipherSecureNote | null {
if (!secureNote || typeof secureNote !== 'object') return null;
const type = Number(secureNote?.type ?? secureNote?.Type ?? 0);
return {
type: Number.isFinite(type) ? type : 0,
};
}
// Format attachments for API response // Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null { export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null; if (attachments.length === 0) return null;
@@ -241,6 +326,196 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
return formatted.length ? formatted : null; return formatted.length ? formatted : null;
} }
function formatAttachmentSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
interface IncomingAttachmentMetadata {
id: string;
fileName?: unknown;
key?: unknown;
fileSize?: unknown;
hasFileName: boolean;
hasKey: boolean;
hasFileSize: boolean;
}
function readIncomingAttachmentMetadataMap(
value: unknown,
options: { legacyFileNameMap?: boolean } = {}
): IncomingAttachmentMetadata[] {
if (!value || typeof value !== 'object') return [];
const out: IncomingAttachmentMetadata[] = [];
if (Array.isArray(value)) {
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const id = String(row.id ?? row.Id ?? '').trim();
if (!id) continue;
const fileName = getAliasedProp(row, ['fileName', 'FileName']);
const key = getAliasedProp(row, ['key', 'Key']);
const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']);
out.push({
id,
fileName: fileName.value,
key: key.value,
fileSize: fileSize.value,
hasFileName: fileName.present,
hasKey: key.present,
hasFileSize: fileSize.present,
});
}
return out;
}
for (const [rawId, rawValue] of Object.entries(value as Record<string, unknown>)) {
const id = String(rawId || '').trim();
if (!id) continue;
if (options.legacyFileNameMap && (typeof rawValue === 'string' || rawValue == null)) {
out.push({
id,
fileName: rawValue,
key: undefined,
fileSize: undefined,
hasFileName: rawValue != null,
hasKey: false,
hasFileSize: false,
});
continue;
}
if (!rawValue || typeof rawValue !== 'object') continue;
const row = rawValue as Record<string, unknown>;
const fileName = getAliasedProp(row, ['fileName', 'FileName']);
const key = getAliasedProp(row, ['key', 'Key']);
const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']);
out.push({
id,
fileName: fileName.value,
key: key.value,
fileSize: fileSize.value,
hasFileName: fileName.present,
hasKey: key.present,
hasFileSize: fileSize.present,
});
}
return out;
}
function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata[] {
const merged = new Map<string, IncomingAttachmentMetadata>();
const legacy = getAliasedProp(source, ['attachments', 'Attachments']);
const current = getAliasedProp(source, ['attachments2', 'Attachments2']);
if (legacy.present) {
for (const item of readIncomingAttachmentMetadataMap(legacy.value, { legacyFileNameMap: true })) {
merged.set(item.id, item);
}
}
if (current.present) {
for (const item of readIncomingAttachmentMetadataMap(current.value)) {
const previous = merged.get(item.id);
merged.set(item.id, {
id: item.id,
fileName: item.hasFileName ? item.fileName : previous?.fileName,
key: item.hasKey ? item.key : previous?.key,
fileSize: item.hasFileSize ? item.fileSize : previous?.fileSize,
hasFileName: item.hasFileName || previous?.hasFileName || false,
hasKey: item.hasKey || previous?.hasKey || false,
hasFileSize: item.hasFileSize || previous?.hasFileSize || false,
});
}
}
return [...merged.values()];
}
function hasIncomingAttachmentMetadata(source: any): boolean {
return readIncomingAttachmentMetadata(source).length > 0;
}
async function syncIncomingAttachmentMetadata(
storage: StorageService,
cipherId: string,
cipherData: any
): Promise<void> {
const incoming = readIncomingAttachmentMetadata(cipherData);
if (!incoming.length) return;
const currentById = new Map((await storage.getAttachmentsByCipher(cipherId)).map((attachment) => [attachment.id, attachment]));
for (const item of incoming) {
const attachment = currentById.get(item.id);
if (!attachment) continue;
let changed = false;
if (item.hasFileName) {
const fileName = String(item.fileName || '').trim();
if (isValidEncString(fileName) && fileName !== attachment.fileName) {
attachment.fileName = fileName;
changed = true;
}
}
if (item.hasKey) {
const key = optionalEncString(item.key);
if (key !== attachment.key) {
attachment.key = key;
changed = true;
}
}
if (item.hasFileSize) {
const size = Number(item.fileSize);
if (Number.isFinite(size) && size >= 0 && size !== Number(attachment.size || 0)) {
attachment.size = size;
attachment.sizeName = formatAttachmentSize(size);
changed = true;
}
}
if (changed) {
await storage.saveAttachment(attachment);
}
}
}
export function applyCipherEmbeddedAttachmentMetadata(cipherData: any, attachments: Attachment[]): Attachment[] {
const incoming = readIncomingAttachmentMetadata(cipherData);
if (!incoming.length || !attachments.length) return attachments;
const incomingById = new Map(incoming.map((item) => [item.id, item]));
return attachments.map((attachment) => {
const item = incomingById.get(attachment.id);
if (!item) return attachment;
const next: Attachment = { ...attachment };
if (item.hasFileName) {
const fileName = String(item.fileName || '').trim();
if (isValidEncString(fileName)) {
next.fileName = fileName;
}
}
if (item.hasKey) {
next.key = optionalEncString(item.key);
}
if (item.hasFileSize) {
const size = Number(item.fileSize);
if (Number.isFinite(size) && size >= 0) {
next.size = size;
next.sizeName = formatAttachmentSize(size);
}
}
return next;
});
}
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null { function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
if (!Array.isArray(fields) || fields.length === 0) return null; if (!Array.isArray(fields) || fields.length === 0) return null;
const out = fields const out = fields
@@ -284,7 +559,8 @@ export function cipherToResponse(
): CipherResponse { ): CipherResponse {
// Strip internal-only fields that must not appear in the API response // Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null); const responseCipherKey = optionalEncString(cipher.key);
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, !!responseCipherKey);
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']); const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [ const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
'title', 'title',
@@ -307,6 +583,10 @@ export function cipherToResponse(
'licenseNumber', 'licenseNumber',
]); ]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
const normalizedSecureNote = Number(cipher.type) === 2
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
: null;
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
return { return {
// Pass through ALL stored cipher fields (known + unknown) // Pass through ALL stored cipher fields (known + unknown)
@@ -328,16 +608,17 @@ export function cipherToResponse(
}, },
object: 'cipherDetails', object: 'cipherDetails',
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [], collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments), attachments: formatAttachments(responseAttachments),
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name, name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
notes: optionalEncString(cipher.notes), notes: optionalEncString(cipher.notes),
login: normalizedLogin, login: normalizedLogin,
card: normalizedCard, card: normalizedCard,
identity: normalizedIdentity, identity: normalizedIdentity,
secureNote: normalizedSecureNote,
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields), fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory), passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
key: optionalEncString(cipher.key), key: responseCipherKey,
encryptedFor: (passthrough as any).encryptedFor ?? null, encryptedFor: (passthrough as any).encryptedFor ?? null,
}; };
} }
@@ -430,6 +711,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']); const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones, // Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
// then override only server-controlled fields. // then override only server-controlled fields.
@@ -447,7 +732,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
deletedAt: null, deletedAt: null,
}; };
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId); cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null); cipher.key = normalizeCipherKeyForStorage(createKey.present ? createKey.value : cipher.key);
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null); cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null); cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null); cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
@@ -464,6 +749,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
if (!folderOk) return errorResponse('Folder not found', 404); if (!folderOk) return errorResponse('Folder not found', 404);
} }
if (hasMissingLoginUriChecksum(cipher)) {
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
}
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
@@ -502,8 +791,13 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']); const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData); const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) { if (incomingKey.present && !shouldAcceptCipherKey(incomingKey.value)) {
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
}
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
} }
@@ -529,7 +823,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.folderId = normalizeOptionalId(incomingFolderId.value); cipher.folderId = normalizeOptionalId(incomingFolderId.value);
} }
if (incomingKey.present) { if (incomingKey.present) {
cipher.key = incomingKey.value ?? null; const normalizedIncomingKey = normalizeCipherKeyForStorage(incomingKey.value);
cipher.key = normalizedIncomingKey || normalizeCipherKeyForStorage(existingCipher.key);
} else {
cipher.key = normalizeCipherKeyForStorage(existingCipher.key);
} }
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null; cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null; cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
@@ -558,6 +855,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
if (!folderOk) return errorResponse('Folder not found', 404); if (!folderOk) return errorResponse('Folder not found', 404);
} }
if (hasMissingLoginUriChecksum(cipher)) {
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
}
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
@@ -584,6 +886,11 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
id: cipher.id,
type: cipher.type,
folderId: cipher.folderId ?? null,
});
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, [])
@@ -608,6 +915,12 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
await storage.deleteCipher(id, userId); await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
folderId: cipher.folderId ?? null,
compat: true,
});
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -629,6 +942,11 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await storage.deleteCipher(id, userId); await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
folderId: cipher.folderId ?? null,
});
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -858,6 +1176,9 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId); const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
count: body.ids.length,
});
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@@ -917,6 +1238,10 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId); const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
count: ownedIds.length,
requestedCount: ids.length,
});
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
+93
View File
@@ -1,11 +1,15 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types'; import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types'; import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
function normalizeIdentifier(value: string | null | undefined): string { function normalizeIdentifier(value: string | null | undefined): string {
return String(value || '').trim(); return String(value || '').trim();
} }
@@ -265,9 +269,50 @@ export async function handleRevokeTrustedDevice(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed }); return jsonResponse({ success: true, removed });
} }
// POST /api/devices/authorized/:deviceIdentifier/permanent
// Upgrades an existing active 2FA remember-token record to permanent trust.
export async function handleTrustDevicePermanently(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
if (!updated) return errorResponse('Device is not currently trusted', 409);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.permanent',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { updated, ...auditRequestMetadata(request) },
});
return jsonResponse({
success: true,
updated,
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
});
}
// DELETE /api/devices/:deviceIdentifier // DELETE /api/devices/:deviceIdentifier
export async function handleDeleteDevice( export async function handleDeleteDevice(
request: Request, request: Request,
@@ -284,8 +329,18 @@ export async function handleDeleteDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
@@ -309,6 +364,15 @@ export async function handleUpdateDeviceName(
const device = await storage.getDevice(userId, normalized); const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404); if (!device) return errorResponse('Device not found', 404);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.name.update',
category: 'device',
level: 'info',
targetType: 'device',
targetId: normalized,
metadata: { name, ...auditRequestMetadata(request) },
});
return jsonResponse(buildDeviceResponse(device)); return jsonResponse(buildDeviceResponse(device));
} }
@@ -327,7 +391,17 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
AuthService.invalidateUserCache(userId);
notifyUserLogout(env, userId, null); notifyUserLogout(env, userId, null);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete_all',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
} }
@@ -419,6 +493,15 @@ export async function handleUntrustDevices(
if (!deviceIdentifier) continue; if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier); await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
} }
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke_batch',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed }); return jsonResponse({ success: true, removed });
} }
@@ -458,8 +541,18 @@ export async function handleDeactivateDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.deactivate',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
+28
View File
@@ -5,6 +5,7 @@ import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
@@ -15,6 +16,27 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
async function writeFolderAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'folder',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Convert internal folder to API response format // Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse { function folderToResponse(folder: Folder): FolderResponse {
return { return {
@@ -134,6 +156,9 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.deleteFolder(id, userId); await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeFolderAudit(storage, request, userId, 'folder.delete', {
id,
});
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -157,6 +182,9 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
const revisionDate = await storage.bulkDeleteFolders(ids, userId); const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
count: ids.length,
});
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
+114 -12
View File
@@ -14,6 +14,7 @@ import {
buildAccountKeys, buildAccountKeys,
buildUserDecryptionOptions, buildUserDecryptionOptions,
} from '../utils/user-decryption'; } from '../utils/user-decryption';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -32,6 +33,17 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null; return null;
} }
async function resolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
if (!deviceInfo.deviceIdentifier) return null;
const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier);
const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID();
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
}
function shouldUseWebSession(request: Request): boolean { function shouldUseWebSession(request: Request): boolean {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
} }
@@ -215,7 +227,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const twoFactorToken = body.twoFactorToken; const twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider; const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember; const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = `${clientIdentifier}:${email}`; const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
@@ -240,11 +252,37 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
if (user.status !== 'active') { if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
if (!valid) { if (!valid) {
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_password',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return recordFailedLoginAndBuildResponse( return recordFailedLoginAndBuildResponse(
rateLimit, rateLimit,
loginIdentifier, loginIdentifier,
@@ -320,10 +358,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful password + (optional) 2FA verification. // Persist device only after successful password + (optional) 2FA verification.
const deviceSession = const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice( await storage.upsertDevice(
user.id, user.id,
@@ -341,6 +376,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = { const response: TokenResponse = {
access_token: accessToken, access_token: accessToken,
@@ -380,7 +430,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope; const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = `${clientIdentifier}:${clientId}`; const loginIdentifier = clientIdentifier;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) { if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
@@ -404,19 +454,42 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
if (user.status !== 'active') { if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_api_key',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
} }
// Persist device only after successful client credential verification. // Persist device only after successful client credential verification.
const deviceSession = const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice( await storage.upsertDevice(
user.id, user.id,
@@ -434,6 +507,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = { const response: TokenResponse = {
access_token: accessToken, access_token: accessToken,
@@ -538,8 +626,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400); return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
} }
const result = await auth.refreshAccessToken(refreshToken); const result = await auth.refreshAccessTokenDetailed(refreshToken);
if (!result) { if (!result.ok) {
await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null,
action: `auth.refresh.failed.${result.reason}`,
category: 'auth',
level: 'warn',
targetType: result.deviceIdentifier ? 'device' : 'refreshToken',
targetId: result.deviceIdentifier ?? null,
metadata: {
grantType,
reason: result.reason,
webSession: shouldUseWebSession(request),
...auditRequestMetadata(request),
},
});
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, invalidResponse, null) ? withWebRefreshCookie(request, invalidResponse, null)
+2 -2
View File
@@ -19,7 +19,7 @@ interface CiphersImportRequest {
sshKey?: any | null; sshKey?: any | null;
key?: string | null; key?: string | null;
login?: { login?: {
uris?: Array<{ uri: string | null; match?: number | null }> | null; uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
username?: string | null; username?: string | null;
password?: string | null; password?: string | null;
totp?: string | null; totp?: string | null;
@@ -195,7 +195,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
uris: login.uris?.map((u: any) => ({ uris: login.uris?.map((u: any) => ({
...u, ...u,
uri: u.uri ?? null, uri: u.uri ?? null,
uriChecksum: null, uriChecksum: u.uriChecksum ?? null,
match: u.match ?? null, match: u.match ?? null,
})) || null, })) || null,
totp: login.totp ?? null, totp: login.totp ?? null,
+38 -3
View File
@@ -29,6 +29,28 @@ import {
setSendPassword, setSendPassword,
validateDeletionDate, validateDeletionDate,
} from './sends-shared'; } from './sends-shared';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
async function writeSendAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'send',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
async function processSendFileUpload( async function processSendFileUpload(
request: Request, request: Request,
@@ -602,7 +624,6 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
} }
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
@@ -620,6 +641,10 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId); await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId,
type: send.type,
});
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
@@ -651,13 +676,16 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
const revisionDate = await storage.bulkDeleteSends(body.ids, userId); const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
count: sends.length,
requestedCount: body.ids.length,
});
} }
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
@@ -669,12 +697,15 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
@@ -687,6 +718,10 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
+18
View File
@@ -7,6 +7,10 @@ import {
handleAdminRevokeInvite, handleAdminRevokeInvite,
handleAdminSetUserStatus, handleAdminSetUserStatus,
handleAdminDeleteUser, handleAdminDeleteUser,
handleAdminListAuditLogs,
handleAdminGetAuditLogSettings,
handleAdminUpdateAuditLogSettings,
handleAdminClearAuditLogs,
} from './handlers/admin'; } from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup'; import { handleAdminBackupRoute } from './router-admin-backup';
@@ -21,6 +25,20 @@ export async function handleAdminRoute(
return handleAdminListUsers(request, env, actorUser); return handleAdminListUsers(request, env, actorUser);
} }
if (path === '/api/admin/logs' && method === 'GET') {
return handleAdminListAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs' && method === 'DELETE') {
return handleAdminClearAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs/settings') {
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
return null;
}
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method); const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
if (adminBackupResponse) return adminBackupResponse; if (adminBackupResponse) return adminBackupResponse;
+7
View File
@@ -11,6 +11,7 @@ import {
handleDeactivateDevice, handleDeactivateDevice,
handleRevokeAllTrustedDevices, handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice, handleRevokeTrustedDevice,
handleTrustDevicePermanently,
handleDeleteAllDevices, handleDeleteAllDevices,
handleDeleteDevice, handleDeleteDevice,
handleUpdateDeviceName, handleUpdateDeviceName,
@@ -44,6 +45,12 @@ export async function handleAuthenticatedDeviceRoute(
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier); return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
} }
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i);
if (permanentAuthorizedDeviceMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'GET') { if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
+63 -11
View File
@@ -115,7 +115,7 @@ function buildConfigResponse(origin: string) {
_icon_service_url: buildIconServiceTemplate(origin), _icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin), _icon_service_csp: buildIconServiceCsp(origin),
featureStates: { featureStates: {
'cipher-key-encryption': true, 'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true, 'email-verification': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,
@@ -144,6 +144,7 @@ function normalizeIconHost(rawHost: string): string | null {
} }
const ICON_UPSTREAM_TIMEOUT_MS = 2500; const ICON_UPSTREAM_TIMEOUT_MS = 2500;
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500; const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783'; const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
@@ -179,6 +180,55 @@ async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
} }
function getPositiveContentLength(headers: Headers): number | null {
const raw = headers.get('Content-Length');
if (!raw) return null;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : null;
}
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
void reader.cancel().catch(() => undefined);
}, ICON_UPSTREAM_TIMEOUT_MS);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
if (timedOut || totalBytes === 0) return null;
const output = new ArrayBuffer(totalBytes);
const bytes = new Uint8Array(output);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function iconResponse(body: BodyInit | null, contentType: string | null): Response { function iconResponse(body: BodyInit | null, contentType: string | null): Response {
return new Response(body, { return new Response(body, {
status: 200, status: 200,
@@ -218,19 +268,19 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!contentType.startsWith('image/')) continue;
if (!source.rejectImage) { const contentLength = getPositiveContentLength(resp.headers);
return iconResponse(resp.body, resp.headers.get('Content-Type')); if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
}
const contentLength = Number(resp.headers.get('Content-Length') || ''); const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) { if (!bytes) continue;
return iconResponse(resp.body, resp.headers.get('Content-Type')); if (
source.rejectImage &&
bytes.byteLength === source.rejectImage.byteLength &&
(await sha256Hex(bytes)) === source.rejectImage.sha256
) {
continue;
} }
const bytes = await resp.arrayBuffer();
if (bytes.byteLength === 0) continue;
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
return iconResponse(bytes, resp.headers.get('Content-Type')); return iconResponse(bytes, resp.headers.get('Content-Type'));
} catch { } catch {
continue; continue;
@@ -286,6 +336,8 @@ export async function handlePublicRoute(
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') { if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default'; const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode); return handleWebsiteIcon(iconMatch[1], fallbackMode);
} }
+209
View File
@@ -0,0 +1,209 @@
import type { Env } from '../types';
import { generateUUID } from '../utils/uuid';
import { StorageService } from './storage';
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
export interface AuditEventInput {
actorUserId?: string | null;
action: string;
category: AuditLogCategory;
level?: AuditLogLevel;
targetType?: string | null;
targetId?: string | null;
metadata?: Record<string, unknown> | null;
}
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
const MAX_METADATA_BYTES = 2048;
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
const AUDIT_CLEANUP_PROBABILITY = 0.02;
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
retentionDays: 90,
maxEntries: null,
};
let lastAuditCleanupAt = 0;
export interface AuditLogSettings {
retentionDays: number | null;
maxEntries: number | null;
}
const ALLOWED_METADATA_KEYS = new Set([
'method',
'path',
'ip',
'userAgent',
'email',
'targetEmail',
'grantType',
'webSession',
'deviceIdentifier',
'deviceType',
'reason',
'status',
'verifyDevices',
'changed',
'removed',
'updated',
'deleted',
'removedTrusted',
'removedSessions',
'removedDevices',
'requested',
'count',
'requestedCount',
'type',
'folderId',
'cipherId',
'size',
'users',
'ciphers',
'attachments',
'skippedAttachments',
'skippedReason',
'replaceExisting',
'provider',
'fileName',
'fileBytes',
'bytes',
'compressedBytes',
'includesAttachments',
'destinationName',
'destinationId',
'destinationType',
'destinationCount',
'scheduledDestinationCount',
'retentionDays',
'maxEntries',
'remotePath',
'trigger',
'prunedFileCount',
'pruneError',
'uploadVerificationAttempts',
'error',
'expiresInHours',
'checksumMismatchAccepted',
]);
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
const parsed = Math.floor(Number(value));
return allowed.includes(parsed) ? parsed : null;
}
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
if (retentionDays) return { retentionDays, maxEntries: null };
if (maxEntries) return { retentionDays: null, maxEntries };
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
return { retentionDays: null, maxEntries: null };
}
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
return { retentionDays: null, maxEntries: null };
}
return {
...DEFAULT_AUDIT_LOG_SETTINGS,
};
}
export function auditRequestMetadata(request: Request): Record<string, unknown> {
const url = new URL(request.url);
return {
method: request.method,
path: url.pathname,
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
userAgent: request.headers.get('User-Agent') || null,
};
}
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(metadata)) {
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
if (value === undefined || value === null || value === '') continue;
if (SENSITIVE_KEY_RE.test(key)) continue;
if (Array.isArray(value)) {
clean[key] = value.length;
continue;
}
if (typeof value === 'object') continue;
clean[key] = value;
}
return clean;
}
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
try {
return normalizeAuditLogSettings(JSON.parse(raw));
} catch {
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
}
}
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
const normalized = normalizeAuditLogSettings(settings);
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
await applyAuditLogRetention(storage, normalized);
return normalized;
}
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
const current = settings || await getAuditLogSettings(storage);
if (current.retentionDays) {
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
await storage.pruneAuditLogs(before);
}
if (current.maxEntries) {
await storage.pruneAuditLogsToMax(current.maxEntries);
}
}
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
const now = Date.now();
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
lastAuditCleanupAt = now;
await applyAuditLogRetention(storage);
}
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
const metadata = sanitizeMetadata(event.metadata || {});
let metadataJson = JSON.stringify(metadata);
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
metadataJson = JSON.stringify({ truncated: true });
}
await storage.createAuditLog({
id: generateUUID(),
actorUserId: event.actorUserId ?? null,
action: event.action,
category: event.category,
level: event.level || 'info',
targetType: event.targetType ?? null,
targetId: event.targetId ?? null,
metadata: metadataJson,
createdAt: new Date().toISOString(),
});
await maybePruneAuditLogs(storage);
}
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
try {
await insertAuditEvent(storage, event);
} catch (error) {
console.error('audit log write failed', error);
}
}
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
await writeAuditEvent(new StorageService(env.DB), event);
}
+73 -29
View File
@@ -6,6 +6,7 @@ import { StorageService } from './storage';
// The client already does heavy PBKDF2 (600k iterations). // The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive. // This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000; const SERVER_HASH_ITERATIONS = 100_000;
const SERVER_HASH_PREFIX = '$s$';
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000; const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry { interface CachedUserEntry {
@@ -23,6 +24,22 @@ export interface VerifiedAccessContext {
user: User; user: User;
} }
export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired'
| 'user_missing'
| 'user_inactive'
| 'device_missing'
| 'device_session_mismatch';
export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null }
| {
ok: false;
reason: RefreshAccessTokenFailureReason;
userId?: string | null;
deviceIdentifier?: string | null;
};
export class AuthService { export class AuthService {
private storage: StorageService; private storage: StorageService;
private static userCache = new Map<string, CachedUserEntry>(); private static userCache = new Map<string, CachedUserEntry>();
@@ -32,6 +49,25 @@ export class AuthService {
this.storage = new StorageService(env.DB); this.storage = new StorageService(env.DB);
} }
static invalidateUserCache(userId: string): void {
const normalizedUserId = String(userId || '').trim();
if (!normalizedUserId) return;
AuthService.userCache.delete(normalizedUserId);
const prefix = `${normalizedUserId}:`;
for (const key of AuthService.deviceCache.keys()) {
if (key.startsWith(prefix)) {
AuthService.deviceCache.delete(key);
}
}
}
static invalidateDeviceCache(userId: string, deviceId: string): void {
const normalizedUserId = String(userId || '').trim();
const normalizedDeviceId = String(deviceId || '').trim();
if (!normalizedUserId || !normalizedDeviceId) return;
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
}
private readCachedUser(userId: string): User | null | undefined { private readCachedUser(userId: string): User | null | undefined {
const cached = AuthService.userCache.get(userId); const cached = AuthService.userCache.get(userId);
if (!cached) return undefined; if (!cached) return undefined;
@@ -98,7 +134,7 @@ export class AuthService {
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations). // Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense). // 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. // Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
async hashPasswordServer(clientHash: string, email: string): Promise<string> { async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
'raw', 'raw',
@@ -116,19 +152,16 @@ export class AuthService {
const bytes = new Uint8Array(bits); const bytes = new Uint8Array(bits);
let binary = ''; let binary = '';
for (const b of bytes) binary += String.fromCharCode(b); for (const b of bytes) binary += String.fromCharCode(b);
return '$s$' + btoa(binary); return SERVER_HASH_PREFIX + btoa(binary);
} }
// Verify password: hash the input the same way, then constant-time compare. // Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> { async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
// New server-hashed passwords are prefixed with "$s$". if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
// Legacy accounts (created before the upgrade) store raw client hashes without prefix. return this.constantTimeEquals(inputHash, storedHash);
if (email && storedHash.startsWith('$s$')) {
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
} }
// Legacy path: direct constant-time comparison of raw client hashes. const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(inputHash, storedHash); return this.constantTimeEquals(serverHash, storedHash);
} }
private constantTimeEquals(a: string, b: string): boolean { private constantTimeEquals(a: string, b: string): boolean {
@@ -204,34 +237,45 @@ export class AuthService {
} }
// Refresh access token // Refresh access token
async refreshAccessToken( async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
const record = await this.storage.getRefreshTokenRecord(refreshToken); const record = await this.storage.getRefreshTokenRecord(refreshToken);
if (!record?.userId) return null; if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
const user = await this.storage.getUserById(record.userId); const user = await this.storage.getUserById(record.userId);
if (!user) return null; if (!user) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
}
if (user.status !== 'active') { if (user.status !== 'active') {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return null; return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
let device: { identifier: string; sessionStamp: string } | null = null; let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) { if (!record.deviceIdentifier || !record.deviceSessionStamp) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); await this.storage.deleteRefreshToken(refreshToken);
if (!boundDevice) { return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
await this.storage.deleteRefreshToken(refreshToken);
return null;
}
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
} }
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
const accessToken = await this.generateAccessToken(user, device); const accessToken = await this.generateAccessToken(user, device);
return { accessToken, user, device }; return { ok: true, accessToken, user, device };
}
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
const result = await this.refreshAccessTokenDetailed(refreshToken);
return result.ok ? result : null;
} }
} }
-19
View File
@@ -409,13 +409,6 @@ export async function loadBackupSettings(storage: StorageService, env: Env, fall
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> { export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
return;
}
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted); await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
} }
@@ -442,12 +435,6 @@ export async function normalizeImportedBackupSettingsValue(
try { try {
const decrypted = await decryptBackupSettingsRuntime(raw, env); const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone); const settings = parseBackupSettings(decrypted, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} catch { } catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it. // Keep imported portable recovery data intact until an admin signs in and repairs it.
@@ -455,12 +442,6 @@ export async function normalizeImportedBackupSettingsValue(
} }
} }
const settings = parseBackupSettings(raw, fallbackTimezone); const settings = parseBackupSettings(raw, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} }
+18 -15
View File
@@ -6,6 +6,8 @@ import type { Env, User } from '../types';
// server's scheduled backup runner. // server's scheduled backup runner.
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for // - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
// active admin public keys so settings can be repaired after restore/migration. // active admin public keys so settings can be repaired after restore/migration.
// Historical/imported databases may not have usable admin public keys; in that
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
// //
// New admin-entered provider secrets, such as mail API keys, should use this // New admin-entered provider secrets, such as mail API keys, should use this
// pattern or a deliberately documented replacement. Do not store provider // pattern or a deliberately documented replacement. Do not store provider
@@ -186,9 +188,6 @@ export async function encryptBackupSettingsEnvelope(
): Promise<string> { ): Promise<string> {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const eligibleUsers = getEligiblePortableUsers(users); const eligibleUsers = getEligiblePortableUsers(users);
if (!eligibleUsers.length) {
throw new Error('No active administrator public keys are available for backup settings recovery');
}
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET); const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey); const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
@@ -205,18 +204,22 @@ export async function encryptBackupSettingsEnvelope(
const wraps: BackupSettingsPortableWrap[] = []; const wraps: BackupSettingsPortableWrap[] = [];
for (const user of eligibleUsers) { for (const user of eligibleUsers) {
const publicKey = await importPortablePublicKey(user.publicKey!); try {
const wrappedKey = new Uint8Array( const publicKey = await importPortablePublicKey(user.publicKey!);
await crypto.subtle.encrypt( const wrappedKey = new Uint8Array(
{ name: PORTABLE_ALGORITHM }, await crypto.subtle.encrypt(
publicKey, { name: PORTABLE_ALGORITHM },
portableDek publicKey,
) portableDek
); )
wraps.push({ );
userId: user.id, wraps.push({
wrappedKey: bytesToBase64(wrappedKey), userId: user.id,
}); wrappedKey: bytesToBase64(wrappedKey),
});
} catch {
// Keep runtime settings usable even if an imported admin key is malformed.
}
} }
const envelope: BackupSettingsEnvelopeV2 = { const envelope: BackupSettingsEnvelopeV2 = {
+121 -2
View File
@@ -1,5 +1,72 @@
import type { AuditLog, Invite } from '../types'; import type { AuditLog, Invite } from '../types';
export interface AuditLogListOptions {
limit: number;
offset: number;
category?: string | null;
level?: string | null;
q?: string | null;
from?: string | null;
to?: string | null;
}
export interface AuditLogListResult {
logs: AuditLog[];
total: number;
hasMore: boolean;
}
function auditLogFromRow(row: any): AuditLog {
return {
id: row.id,
actorUserId: row.actor_user_id ?? null,
actorEmail: row.actor_email ?? null,
action: row.action,
category: row.category || 'system',
level: row.level || 'info',
targetType: row.target_type ?? null,
targetId: row.target_id ?? null,
targetUserEmail: row.target_user_email ?? null,
metadata: row.metadata ?? null,
createdAt: row.created_at,
};
}
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
const conditions: string[] = [];
const params: unknown[] = [];
if (options.from) {
conditions.push('l.created_at >= ?');
params.push(options.from);
}
if (options.to) {
conditions.push('l.created_at <= ?');
params.push(options.to);
}
if (options.category) {
conditions.push('l.category = ?');
params.push(options.category);
}
if (options.level) {
conditions.push('l.level = ?');
params.push(options.level);
}
if (options.q) {
const q = options.q.toLowerCase().slice(0, 48);
const like = `%${q}%`;
conditions.push(
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
);
params.push(like, like, like, like, like, like);
}
return {
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
params,
};
}
export async function createInvite(db: D1Database, invite: Invite): Promise<void> { export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
await db await db
.prepare( .prepare(
@@ -77,8 +144,60 @@ export async function deleteAllInvites(db: D1Database): Promise<number> {
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> { export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
await db await db
.prepare( .prepare(
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
) )
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt) .bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
.run(); .run();
} }
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
const result = await db
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
.bind(beforeIso)
.run();
return Number(result.meta.changes ?? 0);
}
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
const limit = Math.max(1, Math.floor(maxEntries));
const result = await db
.prepare(
'DELETE FROM audit_logs WHERE id IN (' +
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
')'
)
.bind(limit)
.run();
return Number(result.meta.changes ?? 0);
}
export async function clearAuditLogs(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM audit_logs').run();
return Number(result.meta.changes ?? 0);
}
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
const offset = Math.max(0, Math.floor(options.offset || 0));
const { where, params } = buildAuditWhere(options);
const rows = await db
.prepare(
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
'FROM audit_logs l ' +
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
)
.bind(...params, limit + 1, offset)
.all<any>();
const results = rows.results || [];
const logs = results.slice(0, limit).map(auditLogFromRow);
const hasMore = results.length > limit;
return {
logs,
total: offset + logs.length + (hasMore ? 1 : 0),
hasMore,
};
}
+4
View File
@@ -39,6 +39,10 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
'favorite', 'favorite',
'reprompt', 'reprompt',
'key', 'key',
'attachments',
'Attachments',
'attachments2',
'Attachments2',
'createdAt', 'createdAt',
'created_at', 'created_at',
'creationDate', 'creationDate',
+15
View File
@@ -233,6 +233,21 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
} }
export async function updateTrustedTwoFactorTokensExpiryByDevice(
db: D1Database,
userId: string,
deviceIdentifier: string,
expiresAtMs: number
): Promise<number> {
const now = Date.now();
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const result = await db
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
.bind(expiresAtMs, userId, deviceIdentifier, now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function saveTrustedTwoFactorDeviceToken( export async function saveTrustedTwoFactorDeviceToken(
db: D1Database, db: D1Database,
trustedTokenKey: TrustedTokenKeyFn, trustedTokenKey: TrustedTokenKeyFn,
+1 -36
View File
@@ -28,13 +28,6 @@ export async function getRefreshTokenRecord(
db: D1Database, db: D1Database,
refreshTokenKey: RefreshTokenKeyFn, refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn, maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
saveRefreshTokenRecord: (
token: string,
userId: string,
expiresAtMs?: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
) => Promise<void>,
deleteRefreshTokenRecord: (token: string) => Promise<void>, deleteRefreshTokenRecord: (token: string) => Promise<void>,
token: string token: string
): Promise<RefreshTokenRecord | null> { ): Promise<RefreshTokenRecord | null> {
@@ -42,39 +35,11 @@ export async function getRefreshTokenRecord(
await maybeCleanupExpiredRefreshTokens(now); await maybeCleanupExpiredRefreshTokens(now);
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
let row = await db const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(tokenKey) .bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (!row) {
const legacyRow = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(token)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (legacyRow) {
if (legacyRow.expires_at && legacyRow.expires_at < now) {
await deleteRefreshTokenRecord(token);
return null;
}
await saveRefreshTokenRecord(
token,
legacyRow.user_id,
legacyRow.expires_at,
legacyRow.device_identifier ?? null,
legacyRow.device_session_stamp ?? null
);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
return {
userId: legacyRow.user_id,
expiresAt: legacyRow.expires_at,
deviceIdentifier: legacyRow.device_identifier ?? null,
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
};
}
}
if (!row) return null; if (!row) return null;
if (row.expires_at && row.expires_at < now) { if (row.expires_at && row.expires_at < now) {
await deleteRefreshTokenRecord(token); await deleteRefreshTokenRecord(token);
+7 -1
View File
@@ -82,10 +82,16 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
'CREATE TABLE IF NOT EXISTS audit_logs (' + 'CREATE TABLE IF NOT EXISTS audit_logs (' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)', 'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + 'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
+27 -2
View File
@@ -18,12 +18,17 @@ import {
saveUser as saveStoredUser, saveUser as saveStoredUser,
} from './storage-user-repo'; } from './storage-user-repo';
import { import {
type AuditLogListOptions,
createAuditLog as createStoredAuditLog, createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs,
createInvite as createStoredInvite, createInvite as createStoredInvite,
deleteAllInvites as deleteStoredInvites, deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite, getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs,
listInvites as listStoredInvites, listInvites as listStoredInvites,
markInviteUsed as markStoredInviteUsed, markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revokeInvite as revokeStoredInvite, revokeInvite as revokeStoredInvite,
} from './storage-admin-repo'; } from './storage-admin-repo';
import { import {
@@ -96,6 +101,7 @@ import {
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName, updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
} from './storage-device-repo'; } from './storage-device-repo';
import { import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
@@ -116,7 +122,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-05-05-domain-rules-v2'; const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs';
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -278,6 +284,22 @@ export class StorageService {
await createStoredAuditLog(this.db, log); await createStoredAuditLog(this.db, log);
} }
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
return listStoredAuditLogs(this.db, options);
}
async pruneAuditLogs(beforeIso: string): Promise<number> {
return pruneStoredAuditLogs(this.db, beforeIso);
}
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
return pruneStoredAuditLogsToMax(this.db, maxEntries);
}
async clearAuditLogs(): Promise<number> {
return clearStoredAuditLogs(this.db);
}
// --- Domain rules --- // --- Domain rules ---
async getUserDomainSettings(userId: string) { async getUserDomainSettings(userId: string) {
@@ -463,7 +485,6 @@ export class StorageService {
this.db, this.db,
this.refreshTokenKey.bind(this), this.refreshTokenKey.bind(this),
this.maybeCleanupExpiredRefreshTokens.bind(this), this.maybeCleanupExpiredRefreshTokens.bind(this),
this.saveRefreshToken.bind(this),
this.deleteRefreshToken.bind(this), this.deleteRefreshToken.bind(this),
token token
); );
@@ -614,6 +635,10 @@ export class StorageService {
return deleteStoredTrustedTokensByUserId(this.db, userId); return deleteStoredTrustedTokensByUserId(this.db, userId);
} }
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
}
// --- Trusted 2FA remember tokens (device-bound) --- // --- Trusted 2FA remember tokens (device-bound) ---
async saveTrustedTwoFactorDeviceToken( async saveTrustedTwoFactorDeviceToken(
+4 -1
View File
@@ -10,7 +10,6 @@ export interface Env {
// Optional fallback for attachment/send file storage (no credit card required). // Optional fallback for attachment/send file storage (no credit card required).
ATTACHMENTS_KV?: KVNamespace; ATTACHMENTS_KV?: KVNamespace;
JWT_SECRET: string; JWT_SECRET: string;
TOTP_SECRET?: string;
} }
export type UserRole = 'admin' | 'user'; export type UserRole = 'admin' | 'user';
@@ -96,9 +95,13 @@ export interface Invite {
export interface AuditLog { export interface AuditLog {
id: string; id: string;
actorUserId: string | null; actorUserId: string | null;
actorEmail?: string | null;
action: string; action: string;
category: 'auth' | 'security' | 'device' | 'data' | 'system';
level: 'info' | 'warn' | 'error' | 'security';
targetType: string | null; targetType: string | null;
targetId: string | null; targetId: string | null;
targetUserEmail?: string | null;
metadata: string | null; metadata: string | null;
createdAt: string; createdAt: string;
} }
+6 -4
View File
@@ -38,11 +38,10 @@ function isWildcardCorsPath(path: string): boolean {
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } { function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url); const url = new URL(request.url);
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', allowCredentials: false };
}
if (!origin) { if (!origin) {
return { allowOrigin: null, allowCredentials: false }; return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false };
} }
if (origin === url.origin) { if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
@@ -50,6 +49,9 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
if (isExtensionOrigin(origin)) { if (isExtensionOrigin(origin)) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', allowCredentials: false };
}
return { allowOrigin: null, allowCredentials: false }; return { allowOrigin: null, allowCredentials: false };
} }
+28 -4
View File
@@ -22,9 +22,10 @@ import {
saveSession, saveSession,
stripProfileSecrets, stripProfileSecrets,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
import { getSends } from '@/lib/api/send'; import { getSends } from '@/lib/api/send';
import { repairCipherUriChecksums } from '@/lib/api/vault';
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { import {
@@ -69,7 +70,7 @@ import {
createDemoMainRoutesProps, createDemoMainRoutesProps,
} from '@/lib/demo'; } from '@/lib/demo';
import type { AdminBackupSettings } from '@/lib/api/backup'; import type { AdminBackupSettings } from '@/lib/api/backup';
import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import type { VaultCoreSnapshot } from '@/lib/vault-cache'; import type { VaultCoreSnapshot } from '@/lib/vault-cache';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail { function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
@@ -96,6 +97,7 @@ const APP_ROUTE_PATHS = [
'/vault/totp', '/vault/totp',
'/sends', '/sends',
'/admin', '/admin',
'/logs',
'/security/devices', '/security/devices',
'/backup', '/backup',
'/settings', '/settings',
@@ -144,7 +146,9 @@ function resolveSystemTheme(): 'light' | 'dark' {
function readLockTimeoutMinutes(): LockTimeoutMinutes { function readLockTimeoutMinutes(): LockTimeoutMinutes {
if (typeof window === 'undefined') return 15; if (typeof window === 'undefined') return 15;
const value = Number(window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY)); const stored = window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY);
if (stored === null || stored.trim() === '') return 15;
const value = Number(stored);
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15; return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
} }
@@ -228,6 +232,7 @@ export default function App() {
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>(''); const repairAttemptRef = useRef<string>('');
const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null); const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null); const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
const notificationRefreshTimerRef = useRef<number | null>(null); const notificationRefreshTimerRef = useRef<number | null>(null);
@@ -1037,6 +1042,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (session?.accessToken) return; if (session?.accessToken) return;
repairAttemptRef.current = ''; repairAttemptRef.current = '';
uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]); }, [session?.accessToken]);
useEffect(() => { useEffect(() => {
@@ -1077,6 +1083,17 @@ export default function App() {
setDecryptedFolders(result.folders); setDecryptedFolders(result.folders);
setDecryptedCiphers(result.ciphers); setDecryptedCiphers(result.ciphers);
setVaultInitialDecryptDone(true); setVaultInitialDecryptDone(true);
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
if (uriChecksumRepairAttemptRef.current !== repairKey) {
uriChecksumRepairAttemptRef.current = repairKey;
void repairCipherUriChecksums(authedFetch, session, result.ciphers)
.then((uriChecksumCount) => {
if (uriChecksumCount > 0) void refetchVaultCoreData();
})
.catch(() => {
// Best-effort compatibility repair must not interrupt normal vault loading.
});
}
} catch (error) { } catch (error) {
if (!active) return; if (!active) return;
const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2'); const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2');
@@ -1398,6 +1415,7 @@ export default function App() {
if (location === '/vault/totp') return t('txt_verification_code'); if (location === '/vault/totp') return t('txt_verification_code');
if (location === '/sends') return t('nav_sends'); if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel'); if (location === '/admin') return t('nav_admin_panel');
if (location === '/logs') return t('nav_log_center');
if (location === '/security/devices') return t('nav_device_management'); if (location === '/security/devices') return t('nav_device_management');
if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules'); if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules');
if (location === '/backup') return t('nav_backup_strategy'); if (location === '/backup') return t('nav_backup_strategy');
@@ -1424,7 +1442,7 @@ export default function App() {
}, [phase, isImportHashRoute, location, navigate]); }, [phase, isImportHashRoute, location, navigate]);
useEffect(() => { useEffect(() => {
if (phase === 'app' && !isAdminProfile(profile) && location === '/backup' && !profileQuery.isFetching) { if (phase === 'app' && !isAdminProfile(profile) && (location === '/backup' || location === '/logs') && !profileQuery.isFetching) {
navigate('/vault'); navigate('/vault');
} }
}, [phase, profile?.role, profileQuery.isFetching, location, navigate]); }, [phase, profile?.role, profileQuery.isFetching, location, navigate]);
@@ -1475,6 +1493,7 @@ export default function App() {
onDeleteVaultItem: vaultSendActions.deleteVaultItem, onDeleteVaultItem: vaultSendActions.deleteVaultItem,
onArchiveVaultItem: vaultSendActions.archiveVaultItem, onArchiveVaultItem: vaultSendActions.archiveVaultItem,
onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem, onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem,
onRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems, onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems,
onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems, onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems,
onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
@@ -1517,6 +1536,7 @@ export default function App() {
onSaveDomainRules: handleSaveDomainRules, onSaveDomainRules: handleSaveDomainRules,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
onRemoveDevice: accountSecurityActions.openRemoveDevice, onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices, onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
@@ -1526,6 +1546,10 @@ export default function App() {
onToggleUserStatus: adminActions.toggleUserStatus, onToggleUserStatus: adminActions.toggleUserStatus,
onDeleteUser: adminActions.deleteUser, onDeleteUser: adminActions.deleteUser,
onRevokeInvite: adminActions.revokeInvite, onRevokeInvite: adminActions.revokeInvite,
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup, onExportBackup: backupActions.exportBackup,
onImportBackup: backupActions.importBackup, onImportBackup: backupActions.importBackup,
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
@@ -1,4 +1,4 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
@@ -48,11 +48,13 @@ function isAdminProfile(profile: Profile | null): boolean {
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) { export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location; const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile); const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp'; const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules'; const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
const dataActive = props.location === '/backup' || props.isImportRoute; const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/security/devices'; const managementActive = props.location === '/admin' || props.location === '/security/devices' || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode); const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false); const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null); const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
@@ -173,6 +175,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))} {isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))} {renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))} {isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))} {renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
</> </>
); );
@@ -217,6 +220,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
managementActive, managementActive,
<> <>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} {isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))} {renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
</> </>
)} )}
@@ -302,7 +306,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
</div> </div>
</aside> </aside>
<main className="content"> <main className="content">
<div key={routeAnimationKey} className={`route-stage ${props.location === '/settings/domain-rules' ? 'route-stage-fixed' : ''}`}> <div key={routeAnimationKey} className={`route-stage ${isDomainRulesRoute ? 'route-stage-fixed' : ''} ${isLogRoute ? 'route-stage-log-fixed' : ''}`}>
<AppMainRoutes {...props.mainRoutesProps} /> <AppMainRoutes {...props.mainRoutesProps} />
</div> </div>
</main> </main>
+35 -2
View File
@@ -1,13 +1,14 @@
import { lazy, Suspense } from 'preact/compat'; import { lazy, Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { AuditLogFilters } from '@/lib/api/admin';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats'; import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage')); const VaultPage = lazy(() => import('@/components/VaultPage'));
@@ -17,6 +18,7 @@ const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage')); const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage')); const AdminPage = lazy(() => import('@/components/AdminPage'));
const LogCenterPage = lazy(() => import('@/components/LogCenterPage'));
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage')); const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
const ImportPage = lazy(() => import('@/components/ImportPage')); const ImportPage = lazy(() => import('@/components/ImportPage'));
@@ -79,6 +81,7 @@ export interface AppMainRoutesProps {
onDeleteVaultItem: (cipher: Cipher) => Promise<void>; onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
onArchiveVaultItem: (cipher: Cipher) => Promise<void>; onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>; onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
onRestoreVaultItems: (ids: string[]) => Promise<void>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>; onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
@@ -116,6 +119,7 @@ export interface AppMainRoutesProps {
onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>; onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAllDeviceTrust: () => void; onRevokeAllDeviceTrust: () => void;
onRemoveAllDevices: () => void; onRemoveAllDevices: () => void;
@@ -125,6 +129,10 @@ export interface AppMainRoutesProps {
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>; onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>; onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>; onRevokeInvite: (code: string) => Promise<void>;
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>; onExportBackup: (includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
@@ -207,6 +215,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onDelete={props.onDeleteVaultItem} onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem} onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem} onUnarchive={props.onUnarchiveVaultItem}
onRestore={props.onRestoreVaultItems}
onBulkDelete={props.onBulkDeleteVaultItems} onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems} onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems} onBulkRestore={props.onBulkRestoreVaultItems}
@@ -288,6 +297,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<span>{t('nav_admin_panel')}</span> <span>{t('nav_admin_panel')}</span>
</Link> </Link>
)} )}
{isAdmin && (
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
</Link>
)}
{isAdmin && ( {isAdmin && (
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
@@ -322,6 +337,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onRefresh={() => void props.onRefreshAuthorizedDevices()} onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRenameDevice={props.onRenameAuthorizedDevice} onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust} onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice} onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust} onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices} onRemoveAll={props.onRemoveAllDevices}
@@ -378,6 +394,23 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Suspense> </Suspense>
</div> </div>
</Route> </Route>
<Route path="/logs">
{isAdmin ? (
<div className="stack">
<Suspense fallback={<RouteContentFallback />}>
<LogCenterPage
onLoadLogs={props.onLoadAuditLogs}
onLoadSettings={props.onLoadAuditLogSettings}
onSaveSettings={props.onSaveAuditLogSettings}
onClearLogs={props.onClearAuditLogs}
onNotify={props.onNotify}
mobileLayout={props.mobileLayout}
onMobileBack={() => props.onNavigate(props.settingsHomeRoute)}
/>
</Suspense>
</div>
) : null}
</Route>
{importRoutePaths.map((path) => ( {importRoutePaths.map((path) => (
<Route key={path} path={path}> <Route key={path} path={path}>
{renderImportPageRoute()} {renderImportPageRoute()}
+578
View File
@@ -0,0 +1,578 @@
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { ChevronLeft, ChevronRight, Database, RefreshCw, Save, Search, Server, Settings2, ShieldAlert, Smartphone, Trash2, UserRound } from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import type { AuditLogFilters } from '@/lib/api/admin';
import { t } from '@/lib/i18n';
import type { AuditLogCategory, AuditLogEntry, AuditLogLevel, AuditLogListResult, AuditLogSettings } from '@/lib/types';
interface LogCenterPageProps {
onLoadLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
onLoadSettings: () => Promise<AuditLogSettings>;
onSaveSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearLogs: () => Promise<number>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
mobileLayout?: boolean;
onMobileBack?: () => void;
}
type TimeRange = '24h' | '7d' | '30d' | 'all';
type FilterCategory = AuditLogCategory | 'all';
type FilterLevel = AuditLogLevel | 'all';
type RetentionMode = 'days' | 'entries';
const PAGE_SIZE = 50;
const CATEGORY_OPTIONS: Array<{ value: FilterCategory; labelKey: string }> = [
{ value: 'all', labelKey: 'txt_all_logs' },
{ value: 'auth', labelKey: 'txt_log_category_auth' },
{ value: 'security', labelKey: 'txt_log_category_security' },
{ value: 'device', labelKey: 'txt_log_category_device' },
{ value: 'data', labelKey: 'txt_log_category_data' },
{ value: 'system', labelKey: 'txt_log_category_system' },
];
const LEVEL_OPTIONS: Array<{ value: FilterLevel; labelKey: string }> = [
{ value: 'all', labelKey: 'txt_all_levels' },
{ value: 'info', labelKey: 'txt_log_level_info' },
{ value: 'warn', labelKey: 'txt_log_level_warn' },
{ value: 'error', labelKey: 'txt_log_level_error' },
{ value: 'security', labelKey: 'txt_log_level_security' },
];
const RANGE_OPTIONS: Array<{ value: TimeRange; labelKey: string }> = [
{ value: '24h', labelKey: 'txt_last_24_hours' },
{ value: '7d', labelKey: 'txt_last_7_days' },
{ value: '30d', labelKey: 'txt_last_30_days' },
{ value: 'all', labelKey: 'txt_all_time' },
];
const RETENTION_OPTIONS: Array<{ value: string; labelKey: string }> = [
{ value: '7', labelKey: 'txt_log_retention_7d' },
{ value: '30', labelKey: 'txt_log_retention_30d' },
{ value: '90', labelKey: 'txt_log_retention_90d' },
{ value: '180', labelKey: 'txt_log_retention_180d' },
{ value: '365', labelKey: 'txt_log_retention_365d' },
{ value: '0', labelKey: 'txt_log_retention_forever' },
];
const MAX_ENTRY_OPTIONS: Array<{ value: string; labelKey: string }> = [
{ value: '1000', labelKey: 'txt_log_max_1000' },
{ value: '5000', labelKey: 'txt_log_max_5000' },
{ value: '10000', labelKey: 'txt_log_max_10000' },
{ value: '50000', labelKey: 'txt_log_max_50000' },
{ value: '0', labelKey: 'txt_log_max_unlimited' },
];
function parseMetadata(log: AuditLogEntry): Record<string, unknown> {
if (!log.metadata) return {};
try {
const parsed = JSON.parse(log.metadata);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
} catch {
return { raw: log.metadata };
}
}
function inferCategory(log: AuditLogEntry, metadata: Record<string, unknown>): AuditLogCategory {
if (log.category === 'auth' || log.category === 'security' || log.category === 'device' || log.category === 'data' || log.category === 'system') {
return log.category;
}
const category = metadata.category;
if (category === 'auth' || category === 'security' || category === 'device' || category === 'data' || category === 'system') {
return category;
}
if (log.action.startsWith('auth.')) return 'auth';
if (log.action.startsWith('device.')) return 'device';
if (log.action.startsWith('admin.backup.')) return 'data';
if (log.action.startsWith('account.') || log.action.startsWith('user.password.') || log.action.startsWith('user.register.') || log.action.startsWith('admin.user.')) return 'security';
return 'system';
}
function inferLevel(log: AuditLogEntry, metadata: Record<string, unknown>): AuditLogLevel {
if (log.level === 'info' || log.level === 'warn' || log.level === 'error' || log.level === 'security') {
return log.level;
}
const level = metadata.level;
if (level === 'info' || level === 'warn' || level === 'error' || level === 'security') return level;
if (log.action.includes('.failed') || log.action.includes('.error')) return 'error';
if (log.action.includes('password') || log.action.includes('totp') || log.action.includes('delete') || log.action.includes('ban')) return 'security';
return 'info';
}
function humanizeIdentifier(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split('.')
.flatMap((part) => part.split('_'))
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' / ');
}
function keyFor(prefix: string, value: string): string {
return `${prefix}${value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase()}`;
}
function translatedOrHumanized(key: string, fallback: string): string {
const translated = t(key);
return translated === key ? humanizeIdentifier(fallback) : translated;
}
function formatAction(action: string): string {
if (action.startsWith('auth.refresh.failed.')) {
const reason = formatReason(action.slice('auth.refresh.failed.'.length));
return t('txt_log_action_auth_refresh_failed', { reason });
}
return translatedOrHumanized(keyFor('txt_log_action_', action), action);
}
function formatMetaKey(key: string): string {
return translatedOrHumanized(keyFor('txt_log_meta_', key), key);
}
function formatReason(reason: string): string {
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
}
function formatTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function formatMetaValue(value: unknown): string {
if (value === null || value === undefined || value === '') return t('txt_dash');
if (typeof value === 'boolean') return value ? t('txt_yes') : t('txt_no');
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
return JSON.stringify(value);
}
function formatMetaValueForKey(key: string, value: unknown): string {
if (key === 'reason' && typeof value === 'string') return formatReason(value);
if (key === 'trigger' && typeof value === 'string') {
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
}
if (key === 'type' && typeof value === 'string') {
return translatedOrHumanized(keyFor('txt_log_target_type_', value), value);
}
return formatMetaValue(value);
}
function iconForCategory(category: AuditLogCategory) {
if (category === 'auth') return <ShieldAlert size={16} />;
if (category === 'security') return <UserRound size={16} />;
if (category === 'device') return <Smartphone size={16} />;
if (category === 'data') return <Database size={16} />;
return <Server size={16} />;
}
function buildRange(range: TimeRange): { from?: string; to?: string } {
if (range === 'all') return {};
const now = Date.now();
const hours = range === '24h' ? 24 : range === '7d' ? 24 * 7 : 24 * 30;
return {
from: new Date(now - hours * 60 * 60 * 1000).toISOString(),
to: new Date(now).toISOString(),
};
}
function inferRetentionMode(settings: AuditLogSettings): RetentionMode {
return settings.retentionDays === null && settings.maxEntries !== null ? 'entries' : 'days';
}
export default function LogCenterPage(props: LogCenterPageProps) {
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<FilterCategory>('all');
const [level, setLevel] = useState<FilterLevel>('all');
const [range, setRange] = useState<TimeRange>('7d');
const [loading, setLoading] = useState(false);
const [settingsLoading, setSettingsLoading] = useState(false);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [retentionMode, setRetentionMode] = useState<RetentionMode>('days');
const [settings, setSettings] = useState<AuditLogSettings>({ retentionDays: 90, maxEntries: null });
const [error, setError] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const selectedLog = useMemo(() => logs.find((log) => log.id === selectedId) || logs[0] || null, [logs, selectedId]);
const selectedMetadata = useMemo(() => selectedLog ? parseMetadata(selectedLog) : {}, [selectedLog]);
const selectedCategory = selectedLog ? inferCategory(selectedLog, selectedMetadata) : 'system';
const selectedLevel = selectedLog ? inferLevel(selectedLog, selectedMetadata) : 'info';
const page = Math.floor(offset / PAGE_SIZE) + 1;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const load = useCallback(async (nextOffset = offset) => {
setLoading(true);
setError('');
try {
const rangeFilter = buildRange(range);
const result = await props.onLoadLogs({
limit: PAGE_SIZE,
offset: nextOffset,
category,
level,
q: search,
...rangeFilter,
});
setLogs(result.logs);
setTotal(result.total);
setHasMore(result.hasMore);
setOffset(result.offset);
setSelectedId((current) => current && result.logs.some((log) => log.id === current) ? current : result.logs[0]?.id || null);
setMobileDetailOpen(false);
} catch {
setError(t('txt_load_logs_failed'));
props.onNotify('error', t('txt_load_logs_failed'));
} finally {
setLoading(false);
}
}, [category, level, offset, props, range, search]);
useEffect(() => {
void load(0);
}, [category, level, range]);
useEffect(() => {
let cancelled = false;
setSettingsLoading(true);
props.onLoadSettings()
.then((next) => {
if (!cancelled) {
setSettings(next);
setRetentionMode(inferRetentionMode(next));
}
})
.catch(() => {
if (!cancelled) props.onNotify('error', t('txt_load_log_settings_failed'));
})
.finally(() => {
if (!cancelled) setSettingsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
function submitFilters(event: Event): void {
event.preventDefault();
void load(0);
}
async function saveSettings(): Promise<void> {
setSettingsSaving(true);
try {
const next = await props.onSaveSettings(settings);
setSettings(next);
setRetentionMode(inferRetentionMode(next));
setSettingsOpen(false);
setClearConfirmOpen(false);
props.onNotify('success', t('txt_log_settings_saved'));
void load(0);
} catch {
props.onNotify('error', t('txt_log_settings_save_failed'));
} finally {
setSettingsSaving(false);
}
}
async function clearLogs(): Promise<void> {
setSettingsSaving(true);
try {
await props.onClearLogs();
setLogs([]);
setTotal(0);
setHasMore(false);
setOffset(0);
setSelectedId(null);
setMobileDetailOpen(false);
setClearConfirmOpen(false);
setSettingsOpen(false);
props.onNotify('success', t('txt_logs_cleared'));
} catch {
props.onNotify('error', t('txt_clear_logs_failed'));
} finally {
setSettingsSaving(false);
}
}
function selectRetentionMode(nextMode: RetentionMode): void {
setRetentionMode(nextMode);
setSettings((current) => nextMode === 'days'
? { retentionDays: current.retentionDays ?? 90, maxEntries: null }
: { retentionDays: null, maxEntries: current.maxEntries ?? 10_000 });
}
const visibleMetaEntries = selectedLog
? Object.entries(selectedMetadata).filter(([key]) => key !== 'category' && key !== 'level')
: [];
function selectLog(logId: string): void {
setSelectedId(logId);
setSettingsOpen(false);
setClearConfirmOpen(false);
setMobileDetailOpen(true);
}
function handleMobileBack(): void {
if (mobileDetailOpen) {
setMobileDetailOpen(false);
return;
}
props.onMobileBack?.();
}
return (
<div className={`log-center-page ${mobileDetailOpen ? 'log-mobile-detail-open' : ''}`}>
{props.mobileLayout && (
<div className="log-mobile-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={handleMobileBack}>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_back')}
</button>
<button
type="button"
className={`btn btn-secondary log-mobile-settings-trigger ${settingsOpen ? 'active' : ''}`}
aria-label={t('txt_log_settings')}
title={t('txt_log_settings')}
aria-expanded={settingsOpen}
onClick={() => {
setSettingsOpen((open) => !open);
setClearConfirmOpen(false);
}}
>
<Settings2 size={18} />
</button>
</div>
)}
<section className="card log-center-toolbar">
<form className="log-filter-form" onSubmit={submitFilters}>
<label className="field log-search-field">
<span>{t('txt_search')}</span>
<div className="input-action-wrap">
<Search size={15} className="input-leading-icon" />
<input
className="input log-search-input"
value={search}
placeholder={t('txt_log_search_placeholder')}
onInput={(event) => setSearch((event.currentTarget as HTMLInputElement).value)}
/>
</div>
</label>
<label className="field">
<span>{t('txt_log_category')}</span>
<select className="input" value={category} onChange={(event) => setCategory((event.currentTarget as HTMLSelectElement).value as FilterCategory)}>
{CATEGORY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
</label>
<label className="field">
<span>{t('txt_log_level')}</span>
<select className="input" value={level} onChange={(event) => setLevel((event.currentTarget as HTMLSelectElement).value as FilterLevel)}>
{LEVEL_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
</label>
<label className="field">
<span>{t('txt_time_range')}</span>
<select className="input" value={range} onChange={(event) => setRange((event.currentTarget as HTMLSelectElement).value as TimeRange)}>
{RANGE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
</label>
<div className="actions log-filter-actions">
<button type="button" className="btn btn-secondary" disabled={loading} onClick={() => void load(offset)}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
<button
type="button"
className={`btn btn-secondary ${settingsOpen ? 'active' : ''}`}
aria-expanded={settingsOpen}
onClick={() => {
setSettingsOpen((open) => !open);
setClearConfirmOpen(false);
}}
>
<Settings2 size={14} className="btn-icon" />
{t('txt_log_settings')}
</button>
</div>
</form>
{settingsOpen && (
<div className="log-settings-popover">
<div className="section-head log-settings-popover-head">
<h3>{t('txt_log_retention_settings')}</h3>
</div>
<div className="log-settings-mode" role="group" aria-label={t('txt_log_retention_mode')}>
<button
type="button"
className={`log-mode-option ${retentionMode === 'days' ? 'active' : ''}`}
disabled={settingsLoading || settingsSaving}
onClick={() => selectRetentionMode('days')}
>
{t('txt_log_retention_mode_days')}
</button>
<button
type="button"
className={`log-mode-option ${retentionMode === 'entries' ? 'active' : ''}`}
disabled={settingsLoading || settingsSaving}
onClick={() => selectRetentionMode('entries')}
>
{t('txt_log_retention_mode_entries')}
</button>
</div>
{retentionMode === 'days' ? (
<div className="log-settings-retention-block">
<label className="log-settings-label" htmlFor="log-retention-days-select">{t('txt_log_retention_days')}</label>
<div className="log-settings-retention-row">
<select
id="log-retention-days-select"
className="input"
value={String(settings.retentionDays ?? 0)}
disabled={settingsLoading || settingsSaving}
onChange={(event) => setSettings({
retentionDays: Number((event.currentTarget as HTMLSelectElement).value) || null,
maxEntries: null,
})}
>
{RETENTION_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
<button type="button" className="btn btn-primary log-settings-save-btn" disabled={settingsLoading || settingsSaving} onClick={() => void saveSettings()}>
<Save size={14} className="btn-icon" />
{t('txt_save')}
</button>
</div>
</div>
) : (
<div className="log-settings-retention-block">
<label className="log-settings-label" htmlFor="log-max-entries-select">{t('txt_log_max_entries')}</label>
<div className="log-settings-retention-row">
<select
id="log-max-entries-select"
className="input"
value={String(settings.maxEntries ?? 0)}
disabled={settingsLoading || settingsSaving}
onChange={(event) => setSettings({
retentionDays: null,
maxEntries: Number((event.currentTarget as HTMLSelectElement).value) || null,
})}
>
{MAX_ENTRY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
<button type="button" className="btn btn-primary log-settings-save-btn" disabled={settingsLoading || settingsSaving} onClick={() => void saveSettings()}>
<Save size={14} className="btn-icon" />
{t('txt_save')}
</button>
</div>
</div>
)}
<div className="log-settings-danger">
{clearConfirmOpen ? (
<>
<p>{t('txt_clear_logs_confirm')}</p>
<div className="actions log-clear-confirm-actions">
<button type="button" className="btn btn-secondary" disabled={settingsSaving} onClick={() => setClearConfirmOpen(false)}>
{t('txt_cancel')}
</button>
<button type="button" className="btn btn-danger" disabled={settingsSaving} onClick={() => void clearLogs()}>
<Trash2 size={14} className="btn-icon" />
{t('txt_clear_all_logs')}
</button>
</div>
</>
) : (
<button type="button" className="btn btn-danger ghost-danger" disabled={settingsLoading || settingsSaving} onClick={() => setClearConfirmOpen(true)}>
<Trash2 size={14} className="btn-icon" />
{t('txt_clear_all_logs')}
</button>
)}
</div>
</div>
)}
</section>
<div className="log-center-grid">
<section className="card log-list-panel">
<div className="section-head">
<h3>{t('txt_audit_events')}</h3>
<span className="muted-inline">{page} / {totalPages}</span>
</div>
<div className="log-list">
{logs.map((log) => {
const metadata = parseMetadata(log);
const logCategory = inferCategory(log, metadata);
const logLevel = inferLevel(log, metadata);
return (
<button
key={log.id}
type="button"
className={`log-row ${selectedLog?.id === log.id ? 'active' : ''}`}
onClick={() => selectLog(log.id)}
>
<span className={`log-row-icon log-category-${logCategory}`}>{iconForCategory(logCategory)}</span>
<span className="log-row-main">
<strong>{formatAction(log.action)}</strong>
<small>{formatTime(log.createdAt)}</small>
</span>
<span className={`log-level-pill log-level-${logLevel}`}>{t(`txt_log_level_${logLevel}`)}</span>
</button>
);
})}
{loading && !logs.length && <LoadingState lines={5} compact />}
{!loading && !logs.length && <div className="empty empty-comfortable">{t('txt_no_logs_found')}</div>}
{!!error && <div className="local-error">{error}</div>}
</div>
<div className="actions log-pagination">
<button type="button" className="btn btn-secondary small" disabled={loading || offset <= 0} onClick={() => void load(Math.max(0, offset - PAGE_SIZE))}>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_prev')}
</button>
<span className="log-pagination-count">
{Math.min(offset + logs.length, total)} / {total}
</span>
<button type="button" className="btn btn-secondary small" disabled={loading || !hasMore} onClick={() => void load(offset + PAGE_SIZE)}>
{t('txt_next')}
<ChevronRight size={14} className="btn-icon" />
</button>
</div>
</section>
<section className="card log-detail-panel">
{selectedLog ? (
<>
<div className="section-head log-detail-head">
<div>
<h3>{formatAction(selectedLog.action)}</h3>
<p className="muted-inline">{selectedLog.action}</p>
</div>
<span className={`log-level-pill log-level-${selectedLevel}`}>{t(`txt_log_level_${selectedLevel}`)}</span>
</div>
<div className="log-detail-meta">
<div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div>
<div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div>
<div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div>
<div><span>{t('txt_target')}</span><strong>{selectedLog.targetUserEmail || String(selectedMetadata.targetEmail || '') || selectedLog.targetId || selectedLog.targetType || t('txt_dash')}</strong></div>
</div>
<div className="log-detail-json">
<h4>{t('txt_metadata')}</h4>
{visibleMetaEntries.length ? (
<dl>
{visibleMetaEntries.map(([key, value]) => (
<div key={key}>
<dt>{formatMetaKey(key)}</dt>
<dd>{formatMetaValueForKey(key, value)}</dd>
</div>
))}
</dl>
) : (
<div className="empty">{t('txt_no_metadata')}</div>
)}
</div>
</>
) : (
<div className="empty empty-comfortable">{t('txt_no_logs_found')}</div>
)}
</section>
</div>
</div>
);
}
+29 -4
View File
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { AuthorizedDevice } from '@/lib/types'; import type { AuthorizedDevice } from '@/lib/types';
@@ -12,6 +12,7 @@ interface SecurityDevicesPageProps {
onRefresh: () => void; onRefresh: () => void;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>; onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void; onRevokeTrust: (device: AuthorizedDevice) => void;
onTrustPermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void; onRevokeAll: () => void;
onRemoveAll: () => void; onRemoveAll: () => void;
@@ -24,6 +25,12 @@ function formatDateTime(value: string | null | undefined): string {
return date.toLocaleString(); return date.toLocaleString();
} }
function isPermanentTrust(value: string | null | undefined): boolean {
if (!value) return false;
const date = new Date(value);
return !Number.isNaN(date.getTime()) && date.getUTCFullYear() >= 2099;
}
function mapDeviceTypeName(type: number): string { function mapDeviceTypeName(type: number): string {
switch (type) { switch (type) {
case 0: return t('txt_android'); case 0: return t('txt_android');
@@ -101,7 +108,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button> </button>
</div> </div>
)} )}
<table className="table"> <table className="table authorized-devices-table">
<colgroup>
<col className="authorized-devices-col-device" />
<col className="authorized-devices-col-type" />
<col className="authorized-devices-col-status" />
<col className="authorized-devices-col-date" />
<col className="authorized-devices-col-date" />
<col className="authorized-devices-col-trust" />
<col className="authorized-devices-col-actions" />
</colgroup>
<thead> <thead>
<tr> <tr>
<th>{t('txt_device')}</th> <th>{t('txt_device')}</th>
@@ -135,14 +151,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
{device.trusted ? ( {device.trusted ? (
<div className="trusted-cell"> <div className="trusted-cell">
<Clock3 size={13} /> <Clock3 size={13} />
<span>{formatDateTime(device.trustedUntil)}</span> <span>{isPermanentTrust(device.trustedUntil) ? t('txt_permanent_trust') : formatDateTime(device.trustedUntil)}</span>
</div> </div>
) : ( ) : (
<span className="muted-inline">{t('txt_not_trusted')}</span> <span className="muted-inline">{t('txt_not_trusted')}</span>
)} )}
</td> </td>
<td data-label={t('txt_actions')}> <td data-label={t('txt_actions')}>
<div className="actions"> <div className="actions authorized-devices-actions">
<button <button
type="button" type="button"
className="btn btn-secondary small" className="btn btn-secondary small"
@@ -152,6 +168,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_untrust')} {t('txt_untrust')}
</button> </button>
<button
type="button"
className="btn btn-secondary small"
disabled={!device.trusted || !device.trustedUntil || isPermanentTrust(device.trustedUntil)}
onClick={() => props.onTrustPermanently(device)}
>
<ShieldCheck size={14} className="btn-icon" />
{t('txt_trust_permanently')}
</button>
<button <button
type="button" type="button"
className="btn btn-secondary small" className="btn btn-secondary small"
+9 -2
View File
@@ -281,8 +281,15 @@ export default function SettingsPage(props: SettingsPageProps) {
</section> </section>
<section className="card settings-module"> <section className="card settings-module">
<h3>{t('txt_totp')}</h3> <div className="settings-module-head">
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>} <h3>{t('txt_totp')}</h3>
{totpLocked && (
<span className="totp-status-pill">
<ShieldCheck size={14} aria-hidden="true" />
{t('txt_enabled')}
</span>
)}
</div>
<div className="totp-grid"> <div className="totp-grid">
<div className="totp-qr"> <div className="totp-qr">
<img src={qrDataUrl} alt="TOTP QR" /> <img src={qrDataUrl} alt="TOTP QR" />
+16 -1
View File
@@ -45,6 +45,7 @@ interface VaultPageProps {
onDelete: (cipher: Cipher) => Promise<void>; onDelete: (cipher: Cipher) => Promise<void>;
onArchive: (cipher: Cipher) => Promise<void>; onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>; onUnarchive: (cipher: Cipher) => Promise<void>;
onRestore: (ids: string[]) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>; onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>; onBulkRestore: (ids: string[]) => Promise<void>;
@@ -305,9 +306,10 @@ export default function VaultPage(props: VaultPageProps) {
const name = String(cipher.decName || cipher.name || ''); const name = String(cipher.decName || cipher.name || '');
const username = String(cipher.login?.decUsername || ''); const username = String(cipher.login?.decUsername || '');
const uri = firstCipherUri(cipher); const uri = firstCipherUri(cipher);
const cipherId = String(cipher.id || '').trim();
meta.set(cipher.id, { meta.set(cipher.id, {
name, name,
searchText: `${name}\n${username}\n${uri}`.toLowerCase(), searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}`.toLowerCase(),
firstUri: uri, firstUri: uri,
typeKey: cipherTypeKey(Number(cipher.type || 1)), typeKey: cipherTypeKey(Number(cipher.type || 1)),
sortTime: sortTimeValue(cipher), sortTime: sortTimeValue(cipher),
@@ -732,6 +734,18 @@ const folderName = useCallback((id: string | null | undefined): string => {
} }
} }
async function handleRestoreSelected(cipher: Cipher): Promise<void> {
setBusy(true);
try {
await props.onRestore([cipher.id]);
if (isMobileLayout && selectedCipherId === cipher.id) {
setMobilePanel('list');
}
} finally {
setBusy(false);
}
}
async function confirmBulkDelete(): Promise<void> { async function confirmBulkDelete(): Promise<void> {
const ids = Object.entries(selectedMap) const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected) .filter(([, selected]) => selected)
@@ -1148,6 +1162,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
attachmentDownloadPercent={props.attachmentDownloadPercent} attachmentDownloadPercent={props.attachmentDownloadPercent}
onStartEdit={startEdit} onStartEdit={startEdit}
onDelete={setPendingDelete} onDelete={setPendingDelete}
onRestore={(cipher) => void handleRestoreSelected(cipher)}
onArchive={(cipher) => setPendingArchive(cipher)} onArchive={(cipher) => setPendingArchive(cipher)}
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)} onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
/> />
+21 -10
View File
@@ -14,6 +14,7 @@ import {
formatAttachmentSize, formatAttachmentSize,
formatHistoryTime, formatHistoryTime,
formatTotp, formatTotp,
isCipherDeleted,
maskSecret, maskSecret,
openUri, openUri,
parseFieldType, parseFieldType,
@@ -36,6 +37,7 @@ interface VaultDetailViewProps {
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
onStartEdit: () => void; onStartEdit: () => void;
onDelete: (cipher: Cipher) => void; onDelete: (cipher: Cipher) => void;
onRestore: (cipher: Cipher) => void | Promise<void>;
onArchive: (cipher: Cipher) => void | Promise<void>; onArchive: (cipher: Cipher) => void | Promise<void>;
onUnarchive: (cipher: Cipher) => void | Promise<void>; onUnarchive: (cipher: Cipher) => void | Promise<void>;
} }
@@ -84,6 +86,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false); const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const isDeleted = isCipherDeleted(props.selectedCipher);
const passwordHistoryEntries = useMemo( const passwordHistoryEntries = useMemo(
() => () =>
(props.selectedCipher.passwordHistory || []) (props.selectedCipher.passwordHistory || [])
@@ -446,21 +449,29 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="detail-actions"> <div className="detail-actions">
<div className="actions"> <div className="actions">
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}> {isDeleted ? (
<Pencil size={14} className="btn-icon" /> {t('txt_edit')} <button type="button" className="btn btn-secondary" onClick={() => void props.onRestore(props.selectedCipher)}>
</button> <RotateCcw size={14} className="btn-icon" /> {t('txt_restore')}
{isArchived ? (
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button> </button>
) : ( ) : (
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}> <>
<Archive size={14} className="btn-icon" /> {t('txt_archive')} <button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
</button> <Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button>
{isArchived ? (
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
) : (
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}>
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
</button>
)}
</>
)} )}
</div> </div>
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}> <button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')} <Trash2 size={14} className="btn-icon" /> {isDeleted ? t('txt_delete_permanently') : t('txt_delete')}
</button> </button>
</div> </div>
</> </>
@@ -11,6 +11,7 @@ import {
revokeAuthorizedDeviceTrust, revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust,
setTotp, setTotp,
trustAuthorizedDevicePermanently,
updateAuthorizedDeviceName, updateAuthorizedDeviceName,
updateProfile, updateProfile,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
@@ -208,6 +209,26 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
}); });
}, },
openTrustDevicePermanently(device: AuthorizedDevice) {
onSetConfirm({
title: t('txt_trust_device_permanently'),
message: t('txt_trust_device_permanently_for_name', { name: device.name }),
danger: false,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await trustAuthorizedDevicePermanently(authedFetch, device.identifier);
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_trusted_permanently'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_trust_device_permanently_failed'));
}
})();
},
});
},
openRemoveDevice(device: AuthorizedDevice) { openRemoveDevice(device: AuthorizedDevice) {
onSetConfirm({ onSetConfirm({
title: t('txt_remove_device'), title: t('txt_remove_device'),
+66
View File
@@ -41,6 +41,7 @@ import {
encryptFolderImportName, encryptFolderImportName,
getAttachmentDownloadInfo, getAttachmentDownloadInfo,
importCiphers, importCiphers,
permanentDeleteCipher,
type CiphersImportPayload, type CiphersImportPayload,
type ImportedCipherMapEntry, type ImportedCipherMapEntry,
updateCipher, updateCipher,
@@ -223,6 +224,56 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null):
return next; return next;
} }
function isEncryptedFieldUnresolved(raw: unknown, decrypted: unknown): boolean {
const encrypted = String(raw || '').trim();
if (!looksLikeCipherString(encrypted)) return false;
const plain = String(decrypted || '').trim();
return !plain || looksLikeCipherString(plain);
}
function hasUnresolvedCipherData(cipher: Cipher): boolean {
const checks: Array<[unknown, unknown]> = [
[cipher.name, cipher.decName],
[cipher.notes, cipher.decNotes],
[cipher.login?.username, cipher.login?.decUsername],
[cipher.login?.password, cipher.login?.decPassword],
[cipher.login?.totp, cipher.login?.decTotp],
...(cipher.login?.uris || []).map((uri) => [uri.uri, uri.decUri] as [unknown, unknown]),
[cipher.card?.cardholderName, cipher.card?.decCardholderName],
[cipher.card?.number, cipher.card?.decNumber],
[cipher.card?.brand, cipher.card?.decBrand],
[cipher.card?.expMonth, cipher.card?.decExpMonth],
[cipher.card?.expYear, cipher.card?.decExpYear],
[cipher.card?.code, cipher.card?.decCode],
[cipher.identity?.title, cipher.identity?.decTitle],
[cipher.identity?.firstName, cipher.identity?.decFirstName],
[cipher.identity?.middleName, cipher.identity?.decMiddleName],
[cipher.identity?.lastName, cipher.identity?.decLastName],
[cipher.identity?.username, cipher.identity?.decUsername],
[cipher.identity?.company, cipher.identity?.decCompany],
[cipher.identity?.ssn, cipher.identity?.decSsn],
[cipher.identity?.passportNumber, cipher.identity?.decPassportNumber],
[cipher.identity?.licenseNumber, cipher.identity?.decLicenseNumber],
[cipher.identity?.email, cipher.identity?.decEmail],
[cipher.identity?.phone, cipher.identity?.decPhone],
[cipher.identity?.address1, cipher.identity?.decAddress1],
[cipher.identity?.address2, cipher.identity?.decAddress2],
[cipher.identity?.address3, cipher.identity?.decAddress3],
[cipher.identity?.city, cipher.identity?.decCity],
[cipher.identity?.state, cipher.identity?.decState],
[cipher.identity?.postalCode, cipher.identity?.decPostalCode],
[cipher.identity?.country, cipher.identity?.decCountry],
[cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey],
[cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey],
[cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint],
...(cipher.fields || []).flatMap((field) => [
[field.name, field.decName] as [unknown, unknown],
[field.value, field.decValue] as [unknown, unknown],
]),
];
return checks.some(([raw, decrypted]) => isEncryptedFieldUnresolved(raw, decrypted));
}
export default function useVaultSendActions(options: UseVaultSendActionsOptions) { export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
const { const {
authedFetch, authedFetch,
@@ -420,6 +471,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) { async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
if (!session) return; if (!session) return;
if (hasUnresolvedCipherData(cipher)) {
throw new Error(t('txt_decrypt_failed_2'));
}
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : []; const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : []; const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
const previousCipher: Cipher = { const previousCipher: Cipher = {
@@ -490,6 +544,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async deleteVaultItem(cipher: Cipher) { async deleteVaultItem(cipher: Cipher) {
const previousCipher = { ...cipher }; const previousCipher = { ...cipher };
if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) {
try {
await permanentDeleteCipher(authedFetch, cipher.id);
patchCipherBatch([cipher.id], () => null);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_item_deleted_permanently'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_permanent_delete_item_failed'));
throw error;
}
return;
}
const deletedDate = new Date().toISOString(); const deletedDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate })); patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate }));
try { try {
+64 -1
View File
@@ -1,4 +1,4 @@
import type { AdminInvite, AdminUser, ListResponse } from '../types'; import type { AdminInvite, AdminUser, AuditLogCategory, AuditLogEntry, AuditLogLevel, AuditLogListResult, AuditLogSettings, ListResponse } from '../types';
import { parseJson, type AuthedFetch } from './shared'; import { parseJson, type AuthedFetch } from './shared';
export async function listAdminUsers(authedFetch: AuthedFetch): Promise<AdminUser[]> { export async function listAdminUsers(authedFetch: AuthedFetch): Promise<AdminUser[]> {
@@ -51,3 +51,66 @@ export async function deleteUser(authedFetch: AuthedFetch, userId: string): Prom
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete user failed'); if (!resp.ok) throw new Error('Delete user failed');
} }
export interface AuditLogFilters {
limit?: number;
offset?: number;
category?: AuditLogCategory | 'all';
level?: AuditLogLevel | 'all';
q?: string;
from?: string;
to?: string;
}
export async function listAuditLogs(authedFetch: AuthedFetch, filters: AuditLogFilters = {}): Promise<AuditLogListResult> {
const params = new URLSearchParams();
params.set('limit', String(filters.limit || 50));
params.set('offset', String(filters.offset || 0));
if (filters.category && filters.category !== 'all') params.set('category', filters.category);
if (filters.level && filters.level !== 'all') params.set('level', filters.level);
if (filters.q?.trim()) params.set('q', filters.q.trim());
if (filters.from) params.set('from', filters.from);
if (filters.to) params.set('to', filters.to);
const resp = await authedFetch(`/api/admin/logs?${params.toString()}`);
if (!resp.ok) throw new Error('Failed to load audit logs');
const body = await parseJson<ListResponse<AuditLogEntry>>(resp);
return {
logs: body?.data || [],
total: body?.total || 0,
limit: body?.limit || filters.limit || 50,
offset: body?.offset || filters.offset || 0,
hasMore: !!body?.hasMore,
};
}
export async function getAuditLogSettings(authedFetch: AuthedFetch): Promise<AuditLogSettings> {
const resp = await authedFetch('/api/admin/logs/settings');
if (!resp.ok) throw new Error('Failed to load audit log settings');
const body = await parseJson<AuditLogSettings & { object?: string }>(resp);
return {
retentionDays: body?.retentionDays ?? null,
maxEntries: body?.maxEntries ?? null,
};
}
export async function saveAuditLogSettings(authedFetch: AuthedFetch, settings: AuditLogSettings): Promise<AuditLogSettings> {
const resp = await authedFetch('/api/admin/logs/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!resp.ok) throw new Error('Failed to save audit log settings');
const body = await parseJson<AuditLogSettings & { object?: string }>(resp);
return {
retentionDays: body?.retentionDays ?? null,
maxEntries: body?.maxEntries ?? null,
};
}
export async function clearAuditLogs(authedFetch: AuthedFetch): Promise<number> {
const resp = await authedFetch('/api/admin/logs', { method: 'DELETE' });
if (!resp.ok) throw new Error('Failed to clear audit logs');
const body = await parseJson<{ deleted?: number }>(resp);
return Number(body?.deleted || 0);
}
+8
View File
@@ -667,6 +667,14 @@ export async function revokeAuthorizedDeviceTrust(
if (!resp.ok) throw new Error(t('txt_revoke_device_trust_failed')); if (!resp.ok) throw new Error(t('txt_revoke_device_trust_failed'));
} }
export async function trustAuthorizedDevicePermanently(
authedFetch: AuthedFetch,
deviceIdentifier: string
): Promise<void> {
const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}/permanent`, { method: 'POST' });
if (!resp.ok) throw new Error(t('txt_trust_device_permanently_failed'));
}
export async function revokeAllAuthorizedDeviceTrust(authedFetch: AuthedFetch): Promise<void> { export async function revokeAllAuthorizedDeviceTrust(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/devices/authorized', { method: 'DELETE' }); const resp = await authedFetch('/api/devices/authorized', { method: 'DELETE' });
if (!resp.ok) throw new Error(t('txt_revoke_all_device_trust_failed')); if (!resp.ok) throw new Error(t('txt_revoke_all_device_trust_failed'));
+437 -3
View File
@@ -1,4 +1,4 @@
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, sha256Base64 } from '../crypto';
import type { import type {
Cipher, Cipher,
CipherPasswordHistoryEntry, CipherPasswordHistoryEntry,
@@ -19,6 +19,8 @@ import {
import { readResponseBytesWithProgress } from '../download'; import { readResponseBytesWithProgress } from '../download';
import { loadVaultCoreSyncSnapshot } from './vault-sync'; import { loadVaultCoreSyncSnapshot } from './vault-sync';
type CipherLoginData = NonNullable<Cipher['login']>;
export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> { export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey); const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
return body.folders || []; return body.folders || [];
@@ -494,8 +496,11 @@ async function encryptPasswordHistory(
const out: CipherPasswordHistoryEntry[] = []; const out: CipherPasswordHistoryEntry[] = [];
for (const entry of entries) { for (const entry of entries) {
const rawPassword = String(entry?.password || ''); const rawPassword = String(entry?.password || '');
const hasDecryptedPassword = typeof entry?.decPassword === 'string';
const plainPassword = entry?.decPassword ?? rawPassword; const plainPassword = entry?.decPassword ?? rawPassword;
const encryptedPassword = looksLikeCipherString(rawPassword) const encryptedPassword = hasDecryptedPassword
? await encryptTextValue(plainPassword, enc, mac)
: looksLikeCipherString(rawPassword)
? rawPassword ? rawPassword
: await encryptTextValue(plainPassword, enc, mac); : await encryptTextValue(plainPassword, enc, mac);
if (!encryptedPassword) continue; if (!encryptedPassword) continue;
@@ -508,6 +513,133 @@ async function encryptPasswordHistory(
return out.length ? out : null; return out.length ? out : null;
} }
function plainCipherValue(decrypted: unknown, raw: unknown = ''): string {
if (typeof decrypted === 'string' && !looksLikeCipherString(decrypted)) return decrypted;
const value = String(raw ?? '');
return looksLikeCipherString(value) ? '' : value;
}
function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
const type = Number(cipher.type || 1) || 1;
const draft: VaultDraft = {
type,
name: plainCipherValue(cipher.decName, cipher.name).trim() || 'Untitled',
notes: plainCipherValue(cipher.decNotes, cipher.notes),
favorite: !!cipher.favorite,
reprompt: Number(cipher.reprompt || 0) === 1,
folderId: cipher.folderId || '',
loginUsername: '',
loginPassword: '',
loginTotp: '',
loginUris: [{ uri: '', match: null, originalUri: '', extra: {} }],
loginFido2Credentials: [],
cardholderName: '',
cardNumber: '',
cardBrand: '',
cardExpMonth: '',
cardExpYear: '',
cardCode: '',
identTitle: '',
identFirstName: '',
identMiddleName: '',
identLastName: '',
identUsername: '',
identCompany: '',
identSsn: '',
identPassportNumber: '',
identLicenseNumber: '',
identEmail: '',
identPhone: '',
identAddress1: '',
identAddress2: '',
identAddress3: '',
identCity: '',
identState: '',
identPostalCode: '',
identCountry: '',
sshPrivateKey: '',
sshPublicKey: '',
sshFingerprint: '',
customFields: [],
};
draft.customFields = (cipher.fields || [])
.map((field) => ({
type: parseFieldType(field.type ?? 0),
label: plainCipherValue(field.decName, field.name).trim(),
value: plainCipherValue(field.decValue, field.value),
}))
.filter((field) => field.label);
if (type === 1 && cipher.login) {
draft.loginUsername = plainCipherValue(cipher.login.decUsername, cipher.login.username);
draft.loginPassword = plainCipherValue(cipher.login.decPassword, cipher.login.password);
draft.loginTotp = plainCipherValue(cipher.login.decTotp, cipher.login.totp);
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
: [];
const seenUris = new Set<string>();
const uris = (cipher.login.uris || [])
.map((entry) => {
const uri = plainCipherValue(entry.decUri, entry.uri).trim();
const extra = { ...(entry as Record<string, unknown>) };
delete extra.uri;
delete extra.uriChecksum;
delete extra.match;
delete extra.decUri;
return {
uri,
match: typeof entry.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
originalUri: '',
extra,
};
})
.filter((entry) => {
if (!entry.uri) return false;
const key = entry.uri.toLowerCase();
if (seenUris.has(key)) return false;
seenUris.add(key);
return true;
});
draft.loginUris = uris.length ? uris : draft.loginUris;
} else if (type === 3 && cipher.card) {
draft.cardholderName = plainCipherValue(cipher.card.decCardholderName, cipher.card.cardholderName);
draft.cardNumber = plainCipherValue(cipher.card.decNumber, cipher.card.number);
draft.cardBrand = plainCipherValue(cipher.card.decBrand, cipher.card.brand);
draft.cardExpMonth = plainCipherValue(cipher.card.decExpMonth, cipher.card.expMonth);
draft.cardExpYear = plainCipherValue(cipher.card.decExpYear, cipher.card.expYear);
draft.cardCode = plainCipherValue(cipher.card.decCode, cipher.card.code);
} else if (type === 4 && cipher.identity) {
draft.identTitle = plainCipherValue(cipher.identity.decTitle, cipher.identity.title);
draft.identFirstName = plainCipherValue(cipher.identity.decFirstName, cipher.identity.firstName);
draft.identMiddleName = plainCipherValue(cipher.identity.decMiddleName, cipher.identity.middleName);
draft.identLastName = plainCipherValue(cipher.identity.decLastName, cipher.identity.lastName);
draft.identUsername = plainCipherValue(cipher.identity.decUsername, cipher.identity.username);
draft.identCompany = plainCipherValue(cipher.identity.decCompany, cipher.identity.company);
draft.identSsn = plainCipherValue(cipher.identity.decSsn, cipher.identity.ssn);
draft.identPassportNumber = plainCipherValue(cipher.identity.decPassportNumber, cipher.identity.passportNumber);
draft.identLicenseNumber = plainCipherValue(cipher.identity.decLicenseNumber, cipher.identity.licenseNumber);
draft.identEmail = plainCipherValue(cipher.identity.decEmail, cipher.identity.email);
draft.identPhone = plainCipherValue(cipher.identity.decPhone, cipher.identity.phone);
draft.identAddress1 = plainCipherValue(cipher.identity.decAddress1, cipher.identity.address1);
draft.identAddress2 = plainCipherValue(cipher.identity.decAddress2, cipher.identity.address2);
draft.identAddress3 = plainCipherValue(cipher.identity.decAddress3, cipher.identity.address3);
draft.identCity = plainCipherValue(cipher.identity.decCity, cipher.identity.city);
draft.identState = plainCipherValue(cipher.identity.decState, cipher.identity.state);
draft.identPostalCode = plainCipherValue(cipher.identity.decPostalCode, cipher.identity.postalCode);
draft.identCountry = plainCipherValue(cipher.identity.decCountry, cipher.identity.country);
} else if (type === 5 && cipher.sshKey) {
draft.sshPrivateKey = plainCipherValue(cipher.sshKey.decPrivateKey, cipher.sshKey.privateKey);
draft.sshPublicKey = plainCipherValue(cipher.sshKey.decPublicKey, cipher.sshKey.publicKey);
draft.sshFingerprint = plainCipherValue(
cipher.sshKey.decFingerprint,
cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint
);
}
return draft;
}
async function buildUpdatedPasswordHistory( async function buildUpdatedPasswordHistory(
cipher: Cipher | null, cipher: Cipher | null,
draft: VaultDraft, draft: VaultDraft,
@@ -574,12 +706,18 @@ async function encryptUris(
entry?.extra && typeof entry.extra === 'object' entry?.extra && typeof entry.extra === 'object'
? { ...entry.extra } ? { ...entry.extra }
: {}; : {};
if (String(entry?.originalUri || '').trim() !== trimmed) { const canReuseChecksum = String(entry?.originalUri || '').trim() === trimmed;
if (!canReuseChecksum) {
delete preservedExtra.uriChecksum; delete preservedExtra.uriChecksum;
} }
const preservedChecksum = typeof preservedExtra.uriChecksum === 'string' && looksLikeCipherString(preservedExtra.uriChecksum)
? preservedExtra.uriChecksum
: null;
const uriChecksum = preservedChecksum || await encryptTextValue(await sha256Base64(trimmed), enc, mac);
out.push({ out.push({
...preservedExtra, ...preservedExtra,
uri: await encryptTextValue(trimmed, enc, mac), uri: await encryptTextValue(trimmed, enc, mac),
uriChecksum,
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null, match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
}); });
} }
@@ -660,6 +798,292 @@ async function getCipherKeys(
return { enc: userEnc, mac: userMac, key: null }; return { enc: userEnc, mac: userMac, key: null };
} }
async function repairCipherLoginUris(
cipher: Cipher,
enc: Uint8Array,
mac: Uint8Array
): Promise<{ login: Cipher['login']; changed: boolean }> {
if (!cipher.login || !Array.isArray(cipher.login.uris)) {
return { login: cipher.login ?? null, changed: false };
}
let changed = false;
const uris: Array<Record<string, unknown>> = [];
for (const entry of cipher.login.uris) {
if (!entry || typeof entry !== 'object') continue;
const { decUri: _decUri, ...encryptedEntry } = entry as Record<string, unknown>;
const rawUri = typeof entry.uri === 'string' ? entry.uri.trim() : '';
if (!looksLikeCipherString(rawUri)) {
uris.push({ ...encryptedEntry });
continue;
}
let clearUri = String(entry.decUri || '').trim();
if (!clearUri || looksLikeCipherString(clearUri)) {
try {
clearUri = (await decryptStr(rawUri, enc, mac)).trim();
} catch {
uris.push({ ...encryptedEntry });
continue;
}
}
if (!clearUri) {
uris.push({ ...encryptedEntry });
continue;
}
const expectedChecksum = await sha256Base64(clearUri);
let currentChecksumOk = false;
const rawChecksum = typeof entry.uriChecksum === 'string' ? entry.uriChecksum.trim() : '';
if (looksLikeCipherString(rawChecksum)) {
try {
currentChecksumOk = (await decryptStr(rawChecksum, enc, mac)) === expectedChecksum;
} catch {
currentChecksumOk = false;
}
}
if (currentChecksumOk) {
uris.push({ ...encryptedEntry });
continue;
}
uris.push({
...encryptedEntry,
uri: rawUri,
uriChecksum: await encryptTextValue(expectedChecksum, enc, mac),
match: typeof entry.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
});
changed = true;
}
const {
decUsername: _decUsername,
decPassword: _decPassword,
decTotp: _decTotp,
...encryptedLogin
} = cipher.login as Record<string, unknown>;
return {
login: {
...encryptedLogin,
uris: uris as CipherLoginData['uris'],
} as CipherLoginData,
changed,
};
}
export async function repairCipherUriChecksums(
authedFetch: AuthedFetch,
session: SessionState,
ciphers: Cipher[]
): Promise<number> {
if (!session.symEncKey || !session.symMacKey || !Array.isArray(ciphers) || ciphers.length === 0) {
return 0;
}
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
let repaired = 0;
for (const cipher of ciphers) {
if (!cipher?.id || cipher.type !== 1 || !looksLikeCipherString(cipher.key) || !cipher.login || !Array.isArray(cipher.login.uris)) continue;
let itemKey: Uint8Array;
try {
itemKey = await decryptBw(String(cipher.key).trim(), userEnc, userMac);
} catch {
continue;
}
if (itemKey.length < 64) continue;
const keys = { enc: itemKey.slice(0, 32), mac: itemKey.slice(32, 64), key: String(cipher.key).trim() };
const repair = await repairCipherLoginUris(cipher, keys.enc, keys.mac);
if (!repair.changed) continue;
const payload: Record<string, unknown> = {
type: cipher.type,
folderId: cipher.folderId ?? null,
favorite: !!cipher.favorite,
reprompt: cipher.reprompt ?? 0,
name: cipher.name ?? null,
notes: cipher.notes ?? null,
login: repair.login,
fields: Array.isArray(cipher.fields)
? cipher.fields.map(({ decName: _decName, decValue: _decValue, ...field }) => field)
: null,
key: keys.key,
lastKnownRevisionDate: cipher.revisionDate ?? null,
};
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed'));
repaired += 1;
}
return repaired;
}
function getCipherKeyMismatchProbes(cipher: Cipher): string[] {
const candidates = [
cipher.name,
cipher.notes,
cipher.login?.username,
cipher.login?.password,
cipher.login?.totp,
...(cipher.login?.uris || []).map((uri) => uri.uri),
cipher.card?.cardholderName,
cipher.card?.number,
cipher.identity?.title,
cipher.identity?.firstName,
cipher.sshKey?.privateKey,
...(cipher.fields || []).flatMap((field) => [field.name, field.value]),
];
const probes: string[] = [];
const seen = new Set<string>();
for (const value of candidates) {
const probe = String(value || '').trim();
if (!looksLikeCipherString(probe) || seen.has(probe)) continue;
seen.add(probe);
probes.push(probe);
}
return probes;
}
function isResolvedEncryptedField(raw: unknown, decrypted: unknown): boolean {
const encrypted = String(raw || '').trim();
if (!looksLikeCipherString(encrypted)) return true;
const plain = typeof decrypted === 'string' ? decrypted.trim() : '';
return !!plain && !looksLikeCipherString(plain);
}
function hasUnresolvedEncryptedFields(cipher: Cipher): boolean {
const fido2EncryptedFields = (cipher.login?.fido2Credentials || []).flatMap((credential) => [
credential?.credentialId,
credential?.keyType,
credential?.keyAlgorithm,
credential?.keyCurve,
credential?.keyValue,
credential?.rpId,
credential?.rpName,
credential?.userHandle,
credential?.userName,
credential?.userDisplayName,
credential?.counter,
credential?.discoverable,
]);
const checks: Array<[unknown, unknown]> = [
[cipher.name, cipher.decName],
[cipher.notes, cipher.decNotes],
[cipher.login?.username, cipher.login?.decUsername],
[cipher.login?.password, cipher.login?.decPassword],
[cipher.login?.totp, cipher.login?.decTotp],
...(cipher.login?.uris || []).map((uri) => [uri.uri, uri.decUri] as [unknown, unknown]),
[cipher.card?.cardholderName, cipher.card?.decCardholderName],
[cipher.card?.number, cipher.card?.decNumber],
[cipher.card?.brand, cipher.card?.decBrand],
[cipher.card?.expMonth, cipher.card?.decExpMonth],
[cipher.card?.expYear, cipher.card?.decExpYear],
[cipher.card?.code, cipher.card?.decCode],
[cipher.identity?.title, cipher.identity?.decTitle],
[cipher.identity?.firstName, cipher.identity?.decFirstName],
[cipher.identity?.middleName, cipher.identity?.decMiddleName],
[cipher.identity?.lastName, cipher.identity?.decLastName],
[cipher.identity?.username, cipher.identity?.decUsername],
[cipher.identity?.company, cipher.identity?.decCompany],
[cipher.identity?.ssn, cipher.identity?.decSsn],
[cipher.identity?.passportNumber, cipher.identity?.decPassportNumber],
[cipher.identity?.licenseNumber, cipher.identity?.decLicenseNumber],
[cipher.identity?.email, cipher.identity?.decEmail],
[cipher.identity?.phone, cipher.identity?.decPhone],
[cipher.identity?.address1, cipher.identity?.decAddress1],
[cipher.identity?.address2, cipher.identity?.decAddress2],
[cipher.identity?.address3, cipher.identity?.decAddress3],
[cipher.identity?.city, cipher.identity?.decCity],
[cipher.identity?.state, cipher.identity?.decState],
[cipher.identity?.postalCode, cipher.identity?.decPostalCode],
[cipher.identity?.country, cipher.identity?.decCountry],
[cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey],
[cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey],
[cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint],
...(cipher.fields || []).flatMap((field) => [
[field.name, field.decName] as [unknown, unknown],
[field.value, field.decValue] as [unknown, unknown],
]),
...(cipher.passwordHistory || []).map((entry) => [entry.password, entry.decPassword] as [unknown, unknown]),
...fido2EncryptedFields.map((value) => [value, undefined] as [unknown, unknown]),
];
return checks.some(([raw, decrypted]) => !isResolvedEncryptedField(raw, decrypted));
}
async function hasItemKeyFieldMismatch(
cipher: Cipher,
userEnc: Uint8Array,
userMac: Uint8Array
): Promise<boolean> {
if (!looksLikeCipherString(cipher.key)) return false;
const probes = getCipherKeyMismatchProbes(cipher);
if (probes.length === 0) return false;
let itemKey: Uint8Array;
try {
itemKey = await decryptBw(String(cipher.key).trim(), userEnc, userMac);
} catch {
return false;
}
if (itemKey.length < 64) return false;
const itemEnc = itemKey.slice(0, 32);
const itemMac = itemKey.slice(32, 64);
for (const probe of probes) {
try {
await decryptStr(probe, itemEnc, itemMac);
continue;
} catch {
// Try the legacy user-key field path below.
}
try {
await decryptStr(probe, userEnc, userMac);
return true;
} catch {
// Keep scanning in case another field reveals a repairable mismatch.
}
}
return false;
}
export async function repairCipherKeyMismatches(
authedFetch: AuthedFetch,
session: SessionState,
ciphers: Cipher[]
): Promise<number> {
if (!session.symEncKey || !session.symMacKey || !Array.isArray(ciphers) || ciphers.length === 0) {
return 0;
}
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
let repaired = 0;
for (const cipher of ciphers) {
if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue;
if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue;
if (hasUnresolvedEncryptedFields(cipher)) continue;
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher));
repaired += 1;
}
return repaired;
}
async function buildCipherPayload( async function buildCipherPayload(
session: SessionState, session: SessionState,
draft: VaultDraft, draft: VaultDraft,
@@ -703,6 +1127,9 @@ async function buildCipherPayload(
cipher?.login && typeof cipher.login === 'object' cipher?.login && typeof cipher.login === 'object'
? { ...(cipher.login as Record<string, unknown>) } ? { ...(cipher.login as Record<string, unknown>) }
: {}; : {};
delete existingLogin.decUsername;
delete existingLogin.decPassword;
delete existingLogin.decTotp;
payload.login = { payload.login = {
...existingLogin, ...existingLogin,
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
@@ -803,6 +1230,13 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string):
return (await parseJson<Cipher>(resp))!; return (await parseJson<Cipher>(resp))!;
} }
export async function permanentDeleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/delete`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Permanent delete item failed');
}
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> { export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const id = String(cipherId || '').trim(); const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required'); if (!id) throw new Error('Cipher id is required');
+6
View File
@@ -22,6 +22,12 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
return new Uint8Array(bytes).buffer; return new Uint8Array(bytes).buffer;
} }
export async function sha256Base64(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
return bytesToBase64(new Uint8Array(hash));
}
const hmacSha256KeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>(); const hmacSha256KeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
const aesCbcEncryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>(); const aesCbcEncryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
const aesCbcDecryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>(); const aesCbcDecryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
+64 -42
View File
@@ -1,13 +1,29 @@
import { decryptStr, decryptBw } from './crypto'; import { decryptStr, decryptBw } from './crypto';
import { looksLikeCipherString } from './app-support';
import type { Cipher } from './types'; import type { Cipher } from './types';
async function decryptField( async function decryptCipherField(
value: string | null | undefined, value: string | null | undefined,
enc: Uint8Array, itemEnc: Uint8Array,
mac: Uint8Array, itemMac: Uint8Array,
userEnc: Uint8Array,
userMac: Uint8Array,
canFallbackToUserKey: boolean,
): Promise<string> { ): Promise<string> {
if (!value || typeof value !== 'string') return ''; if (!value || typeof value !== 'string') return '';
try { return await decryptStr(value, enc, mac); } catch { return value; } try {
return await decryptStr(value, itemEnc, itemMac);
} catch {
// Try the legacy user-key path for mixed key/field ciphers.
}
if (canFallbackToUserKey) {
try {
return await decryptStr(value, userEnc, userMac);
} catch {
// Preserve the old raw fallback for fields that are genuinely unreadable.
}
}
return looksLikeCipherString(value) ? '' : value;
} }
export async function decryptSingleCipher( export async function decryptSingleCipher(
@@ -17,29 +33,35 @@ export async function decryptSingleCipher(
): Promise<Cipher> { ): Promise<Cipher> {
let itemEnc = userEnc; let itemEnc = userEnc;
let itemMac = userMac; let itemMac = userMac;
let usesItemKey = false;
if (encrypted.key) { if (encrypted.key) {
try { try {
const itemKey = await decryptBw(encrypted.key, userEnc, userMac); const itemKey = await decryptBw(encrypted.key, userEnc, userMac);
itemEnc = itemKey.slice(0, 32); if (itemKey.length >= 64) {
itemMac = itemKey.slice(32, 64); itemEnc = itemKey.slice(0, 32);
itemMac = itemKey.slice(32, 64);
usesItemKey = true;
}
} catch { /* keep user key */ } } catch { /* keep user key */ }
} }
const canFallbackToUserKey = usesItemKey;
const decrypted: Cipher = { const decrypted: Cipher = {
...encrypted, ...encrypted,
decName: await decryptField(encrypted.name, itemEnc, itemMac), decName: await decryptCipherField(encrypted.name, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decNotes: await decryptField(encrypted.notes, itemEnc, itemMac), decNotes: await decryptCipherField(encrypted.notes, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}; };
if (encrypted.login) { if (encrypted.login) {
decrypted.login = { decrypted.login = {
...encrypted.login, ...encrypted.login,
decUsername: await decryptField(encrypted.login.username, itemEnc, itemMac), decUsername: await decryptCipherField(encrypted.login.username, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPassword: await decryptField(encrypted.login.password, itemEnc, itemMac), decPassword: await decryptCipherField(encrypted.login.password, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decTotp: await decryptField(encrypted.login.totp, itemEnc, itemMac), decTotp: await decryptCipherField(encrypted.login.totp, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
uris: await Promise.all((encrypted.login.uris || []).map(async (u) => ({ uris: await Promise.all((encrypted.login.uris || []).map(async (u) => ({
...u, ...u,
decUri: await decryptField(u.uri, itemEnc, itemMac), decUri: await decryptCipherField(u.uri, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}))), }))),
}; };
} }
@@ -48,7 +70,7 @@ export async function decryptSingleCipher(
decrypted.passwordHistory = await Promise.all( decrypted.passwordHistory = await Promise.all(
encrypted.passwordHistory.map(async (entry) => ({ encrypted.passwordHistory.map(async (entry) => ({
...entry, ...entry,
decPassword: await decryptField(entry?.password, itemEnc, itemMac), decPassword: await decryptCipherField(entry?.password, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
})) }))
); );
} }
@@ -56,36 +78,36 @@ export async function decryptSingleCipher(
if (encrypted.card) { if (encrypted.card) {
decrypted.card = { decrypted.card = {
...encrypted.card, ...encrypted.card,
decCardholderName: await decryptField(encrypted.card.cardholderName, itemEnc, itemMac), decCardholderName: await decryptCipherField(encrypted.card.cardholderName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decNumber: await decryptField(encrypted.card.number, itemEnc, itemMac), decNumber: await decryptCipherField(encrypted.card.number, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decBrand: await decryptField(encrypted.card.brand, itemEnc, itemMac), decBrand: await decryptCipherField(encrypted.card.brand, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decExpMonth: await decryptField(encrypted.card.expMonth, itemEnc, itemMac), decExpMonth: await decryptCipherField(encrypted.card.expMonth, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decExpYear: await decryptField(encrypted.card.expYear, itemEnc, itemMac), decExpYear: await decryptCipherField(encrypted.card.expYear, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decCode: await decryptField(encrypted.card.code, itemEnc, itemMac), decCode: await decryptCipherField(encrypted.card.code, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}; };
} }
if (encrypted.identity) { if (encrypted.identity) {
decrypted.identity = { decrypted.identity = {
...encrypted.identity, ...encrypted.identity,
decTitle: await decryptField(encrypted.identity.title, itemEnc, itemMac), decTitle: await decryptCipherField(encrypted.identity.title, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decFirstName: await decryptField(encrypted.identity.firstName, itemEnc, itemMac), decFirstName: await decryptCipherField(encrypted.identity.firstName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decMiddleName: await decryptField(encrypted.identity.middleName, itemEnc, itemMac), decMiddleName: await decryptCipherField(encrypted.identity.middleName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decLastName: await decryptField(encrypted.identity.lastName, itemEnc, itemMac), decLastName: await decryptCipherField(encrypted.identity.lastName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decUsername: await decryptField(encrypted.identity.username, itemEnc, itemMac), decUsername: await decryptCipherField(encrypted.identity.username, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decCompany: await decryptField(encrypted.identity.company, itemEnc, itemMac), decCompany: await decryptCipherField(encrypted.identity.company, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decSsn: await decryptField(encrypted.identity.ssn, itemEnc, itemMac), decSsn: await decryptCipherField(encrypted.identity.ssn, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPassportNumber: await decryptField(encrypted.identity.passportNumber, itemEnc, itemMac), decPassportNumber: await decryptCipherField(encrypted.identity.passportNumber, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decLicenseNumber: await decryptField(encrypted.identity.licenseNumber, itemEnc, itemMac), decLicenseNumber: await decryptCipherField(encrypted.identity.licenseNumber, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decEmail: await decryptField(encrypted.identity.email, itemEnc, itemMac), decEmail: await decryptCipherField(encrypted.identity.email, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPhone: await decryptField(encrypted.identity.phone, itemEnc, itemMac), decPhone: await decryptCipherField(encrypted.identity.phone, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decAddress1: await decryptField(encrypted.identity.address1, itemEnc, itemMac), decAddress1: await decryptCipherField(encrypted.identity.address1, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decAddress2: await decryptField(encrypted.identity.address2, itemEnc, itemMac), decAddress2: await decryptCipherField(encrypted.identity.address2, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decAddress3: await decryptField(encrypted.identity.address3, itemEnc, itemMac), decAddress3: await decryptCipherField(encrypted.identity.address3, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decCity: await decryptField(encrypted.identity.city, itemEnc, itemMac), decCity: await decryptCipherField(encrypted.identity.city, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decState: await decryptField(encrypted.identity.state, itemEnc, itemMac), decState: await decryptCipherField(encrypted.identity.state, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPostalCode: await decryptField(encrypted.identity.postalCode, itemEnc, itemMac), decPostalCode: await decryptCipherField(encrypted.identity.postalCode, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decCountry: await decryptField(encrypted.identity.country, itemEnc, itemMac), decCountry: await decryptCipherField(encrypted.identity.country, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}; };
} }
@@ -93,11 +115,11 @@ export async function decryptSingleCipher(
const fingerprint = encrypted.sshKey.keyFingerprint || encrypted.sshKey.fingerprint || ''; const fingerprint = encrypted.sshKey.keyFingerprint || encrypted.sshKey.fingerprint || '';
decrypted.sshKey = { decrypted.sshKey = {
...encrypted.sshKey, ...encrypted.sshKey,
decPrivateKey: await decryptField(encrypted.sshKey.privateKey, itemEnc, itemMac), decPrivateKey: await decryptCipherField(encrypted.sshKey.privateKey, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPublicKey: await decryptField(encrypted.sshKey.publicKey, itemEnc, itemMac), decPublicKey: await decryptCipherField(encrypted.sshKey.publicKey, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
keyFingerprint: fingerprint || null, keyFingerprint: fingerprint || null,
fingerprint: fingerprint || null, fingerprint: fingerprint || null,
decFingerprint: await decryptField(fingerprint, itemEnc, itemMac), decFingerprint: await decryptCipherField(fingerprint, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}; };
} }
@@ -105,8 +127,8 @@ export async function decryptSingleCipher(
decrypted.fields = await Promise.all( decrypted.fields = await Promise.all(
encrypted.fields.map(async (field) => ({ encrypted.fields.map(async (field) => ({
...field, ...field,
decName: await decryptField(field.name, itemEnc, itemMac), decName: await decryptCipherField(field.name, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decValue: await decryptField(field.value, itemEnc, itemMac), decValue: await decryptCipherField(field.value, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
})) }))
); );
} }
+27
View File
@@ -932,6 +932,11 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
notify('success', t('txt_item_updated')); notify('success', t('txt_item_updated'));
}, },
onDeleteVaultItem: async (cipher) => { onDeleteVaultItem: async (cipher) => {
if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) {
state.setCiphers((prev) => prev.filter((item) => item.id !== cipher.id));
notify('success', t('txt_item_deleted_permanently'));
return;
}
const deletedDate = new Date().toISOString(); const deletedDate = new Date().toISOString();
state.setCiphers((prev) => prev.map((item) => ( state.setCiphers((prev) => prev.map((item) => (
item.id === cipher.id ? { ...item, deletedDate, archivedDate: null, revisionDate: deletedDate } : item item.id === cipher.id ? { ...item, deletedDate, archivedDate: null, revisionDate: deletedDate } : item
@@ -965,6 +970,11 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
state.setCiphers((prev) => prev.filter((item) => !idSet.has(item.id))); state.setCiphers((prev) => prev.filter((item) => !idSet.has(item.id)));
notify('success', t('txt_deleted_selected_items_permanently')); notify('success', t('txt_deleted_selected_items_permanently'));
}, },
onRestoreVaultItems: async (ids) => {
const idSet = new Set(ids);
state.setCiphers((prev) => prev.map((item) => (idSet.has(item.id) ? { ...item, deletedDate: null } : item)));
notify('success', t('txt_restored_selected_items'));
},
onBulkRestoreVaultItems: async (ids) => { onBulkRestoreVaultItems: async (ids) => {
const idSet = new Set(ids); const idSet = new Set(ids);
state.setCiphers((prev) => prev.map((item) => (idSet.has(item.id) ? { ...item, deletedDate: null } : item))); state.setCiphers((prev) => prev.map((item) => (idSet.has(item.id) ? { ...item, deletedDate: null } : item)));
@@ -1083,6 +1093,14 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
))); )));
notify('success', t('txt_device_authorization_revoked')); notify('success', t('txt_device_authorization_revoked'));
}, },
onTrustDevicePermanently: (device) => {
state.setAuthorizedDevices((prev) => prev.map((item) => (
item.identifier === device.identifier && item.trusted
? { ...item, trustedUntil: '2099-12-31T23:59:59.000Z', revisionDate: new Date().toISOString() }
: item
)));
notify('success', t('txt_device_trusted_permanently'));
},
onRemoveDevice: (device) => { onRemoveDevice: (device) => {
state.setAuthorizedDevices((prev) => prev.filter((item) => item.identifier !== device.identifier)); state.setAuthorizedDevices((prev) => prev.filter((item) => item.identifier !== device.identifier));
notify('success', t('txt_device_removed')); notify('success', t('txt_device_removed'));
@@ -1129,6 +1147,15 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
))); )));
notify('success', t('txt_invite_revoked')); notify('success', t('txt_invite_revoked'));
}, },
onLoadAuditLogSettings: async () => ({ retentionDays: 90, maxEntries: null }),
onSaveAuditLogSettings: async (settings) => {
notify('success', t('txt_log_settings_saved'));
return settings;
},
onClearAuditLogs: async () => {
notify('success', t('txt_logs_cleared'));
return 0;
},
onExportBackup: async () => { onExportBackup: async () => {
notify('success', t('txt_backup_export_success')); notify('success', t('txt_backup_export_success'));
}, },
+193
View File
@@ -2,6 +2,7 @@
const en: Record<string, string> = { const en: Record<string, string> = {
"nav_account_settings": "Account Settings", "nav_account_settings": "Account Settings",
"nav_admin_panel": "Admin Panel", "nav_admin_panel": "Admin Panel",
"nav_log_center": "Log Center",
"nav_device_management": "Device Management", "nav_device_management": "Device Management",
"nav_my_vault": "My Vault", "nav_my_vault": "My Vault",
"nav_vault_items": "Vault", "nav_vault_items": "Vault",
@@ -368,6 +369,7 @@ const en: Record<string, string> = {
"txt_delete_item": "Delete Item", "txt_delete_item": "Delete Item",
"txt_delete_passkey": "Delete Passkey", "txt_delete_passkey": "Delete Passkey",
"txt_delete_item_failed": "Delete item failed", "txt_delete_item_failed": "Delete item failed",
"txt_permanent_delete_item_failed": "Permanent delete item failed",
"txt_delete_permanently": "Delete Permanently", "txt_delete_permanently": "Delete Permanently",
"txt_archive": "Archive", "txt_archive": "Archive",
"txt_archive_item": "Archive Item", "txt_archive_item": "Archive Item",
@@ -500,6 +502,7 @@ const en: Record<string, string> = {
"txt_item": "Item", "txt_item": "Item",
"txt_item_created": "Item created", "txt_item_created": "Item created",
"txt_item_deleted": "Item deleted", "txt_item_deleted": "Item deleted",
"txt_item_deleted_permanently": "Item permanently deleted",
"txt_item_history": "Item History", "txt_item_history": "Item History",
"txt_password_history": "Password History", "txt_password_history": "Password History",
"txt_password_updated_value": "Password updated: {value}", "txt_password_updated_value": "Password updated: {value}",
@@ -690,6 +693,12 @@ const en: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "Failed to revoke all device trust", "txt_revoke_all_device_trust_failed": "Failed to revoke all device trust",
"txt_revoke_trust": "Revoke Trust", "txt_revoke_trust": "Revoke Trust",
"txt_untrust": "Untrust", "txt_untrust": "Untrust",
"txt_trust_permanently": "Trust permanently",
"txt_trust_device_permanently": "Trust device permanently",
"txt_trust_device_permanently_for_name": "Upgrade \"{name}\" from 30-day trust to permanent trust?",
"txt_trust_device_permanently_failed": "Failed to trust device permanently",
"txt_device_trusted_permanently": "Device trusted permanently",
"txt_permanent_trust": "Permanent trust",
"txt_update_device_note_failed": "Update device note failed", "txt_update_device_note_failed": "Update device note failed",
"txt_role": "Role", "txt_role": "Role",
"txt_save": "Save", "txt_save": "Save",
@@ -935,6 +944,190 @@ const en: Record<string, string> = {
"txt_nav_layout_grouped_expanded_desc": "Keep all groups expanded", "txt_nav_layout_grouped_expanded_desc": "Keep all groups expanded",
"txt_nav_layout_grouped_smart": "Smart groups", "txt_nav_layout_grouped_smart": "Smart groups",
"txt_nav_layout_grouped_smart_desc": "Open active groups as needed", "txt_nav_layout_grouped_smart_desc": "Open active groups as needed",
"txt_actor": "Actor",
"txt_all_levels": "All levels",
"txt_all_logs": "All logs",
"txt_all_time": "All time",
"txt_audit_events": "Log list",
"txt_filter": "Filter",
"txt_last_24_hours": "Last 24 hours",
"txt_last_7_days": "Last 7 days",
"txt_last_30_days": "Last 30 days",
"txt_load_logs_failed": "Failed to load logs",
"txt_load_log_settings_failed": "Failed to load log settings",
"txt_log_category": "Category",
"txt_log_category_auth": "Auth & sessions",
"txt_log_category_data": "Data operations",
"txt_log_category_device": "Devices",
"txt_log_category_security": "Account security",
"txt_log_category_system": "System",
"txt_log_center_description": "Trace sign-ins, refresh failures, device events, security changes, backup actions, and admin operations.",
"txt_log_center_title": "Log Center",
"txt_log_level": "Level",
"txt_log_level_error": "Error",
"txt_log_level_info": "Info",
"txt_log_level_security": "Security",
"txt_log_level_warn": "Warn",
"txt_log_action_account_api_key_create": "Create API key",
"txt_log_action_account_api_key_rotate": "Rotate API key",
"txt_log_action_account_keys_update": "Update account keys",
"txt_log_action_account_profile_update": "Update account profile",
"txt_log_action_account_totp_disable": "Disable two-step login",
"txt_log_action_account_totp_enable": "Enable two-step login",
"txt_log_action_account_totp_recover": "Recover two-step login",
"txt_log_action_account_verify_devices_update": "Update device verification",
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
"txt_log_action_admin_backup_export": "Export backup",
"txt_log_action_admin_backup_import": "Import backup",
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status",
"txt_log_action_attachment_delete": "Delete attachment",
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
"txt_log_action_auth_login_success": "Login succeeded",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
"txt_log_action_device_deactivate": "Deactivate device",
"txt_log_action_device_delete": "Delete device",
"txt_log_action_device_delete_all": "Delete all devices",
"txt_log_action_device_name_update": "Update device name",
"txt_log_action_device_trust_permanent": "Trust device permanently",
"txt_log_action_device_trust_revoke": "Revoke device trust",
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
"txt_log_action_folder_delete": "Delete folder",
"txt_log_action_folder_delete_bulk": "Delete folders",
"txt_log_action_send_auth_remove": "Remove Send authentication",
"txt_log_action_send_delete": "Delete Send",
"txt_log_action_send_delete_bulk": "Delete Sends",
"txt_log_action_send_password_remove": "Remove Send password",
"txt_log_action_user_password_change": "Change master password",
"txt_log_action_user_register_first_admin": "Register first admin",
"txt_log_action_user_register_invite": "Register by invite",
"txt_log_meta_attachments": "Attachments",
"txt_log_meta_bytes": "Bytes",
"txt_log_meta_changed": "Changed fields",
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
"txt_log_meta_cipher_id": "Vault item ID",
"txt_log_meta_ciphers": "Vault items",
"txt_log_meta_compat": "Compatibility",
"txt_log_meta_compressed_bytes": "Compressed bytes",
"txt_log_meta_count": "Count",
"txt_log_meta_deleted": "Deleted count",
"txt_log_meta_destination_count": "Destination count",
"txt_log_meta_destination_id": "Destination ID",
"txt_log_meta_destination_name": "Destination name",
"txt_log_meta_destination_type": "Destination type",
"txt_log_meta_device_identifier": "Device ID",
"txt_log_meta_device_type": "Device type",
"txt_log_meta_email": "Email",
"txt_log_meta_error": "Error",
"txt_log_meta_expires_in_hours": "Expires in hours",
"txt_log_meta_file_bytes": "File bytes",
"txt_log_meta_file_name": "File name",
"txt_log_meta_folder_id": "Folder ID",
"txt_log_meta_grant_type": "Login method",
"txt_log_meta_includes_attachments": "Includes attachments",
"txt_log_meta_ip": "IP address",
"txt_log_meta_max_entries": "Entry limit",
"txt_log_meta_method": "Request method",
"txt_log_meta_path": "Request path",
"txt_log_meta_provider": "Provider",
"txt_log_meta_prune_error": "Cleanup error",
"txt_log_meta_pruned_file_count": "Cleaned files",
"txt_log_meta_raw": "Raw data",
"txt_log_meta_reason": "Reason",
"txt_log_meta_remote_path": "Remote path",
"txt_log_meta_removed": "Removed count",
"txt_log_meta_removed_devices": "Removed devices",
"txt_log_meta_removed_sessions": "Removed sessions",
"txt_log_meta_removed_trusted": "Trust removals",
"txt_log_meta_replace_existing": "Replace existing data",
"txt_log_meta_requested": "Requested count",
"txt_log_meta_requested_count": "Requested count",
"txt_log_meta_retention_days": "Retention days",
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
"txt_log_meta_size": "Size",
"txt_log_meta_skipped_attachments": "Skipped attachments",
"txt_log_meta_skipped_reason": "Skip reason",
"txt_log_meta_status": "Status",
"txt_log_meta_target_email": "Target email",
"txt_log_meta_trigger": "Trigger",
"txt_log_meta_type": "Type",
"txt_log_meta_updated": "Updated count",
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
"txt_log_meta_user_agent": "Browser/client",
"txt_log_meta_users": "Users",
"txt_log_meta_verify_devices": "Verify devices",
"txt_log_meta_web_session": "Web session",
"txt_log_reason_bad_api_key": "Bad API key",
"txt_log_reason_bad_password": "Bad password",
"txt_log_reason_device_missing": "Device missing",
"txt_log_reason_device_session_mismatch": "Device session mismatch",
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
"txt_log_reason_user_inactive": "User inactive",
"txt_log_reason_user_missing": "User missing",
"txt_log_target_type_attachment": "Attachment",
"txt_log_target_type_audit_log": "Log",
"txt_log_target_type_backup": "Backup",
"txt_log_target_type_cipher": "Vault item",
"txt_log_target_type_device": "Device",
"txt_log_target_type_folder": "Folder",
"txt_log_target_type_invite": "Invite",
"txt_log_target_type_refresh_token": "Refresh token",
"txt_log_target_type_send": "Send",
"txt_log_target_type_user": "User",
"txt_log_trigger_manual": "Manual",
"txt_log_trigger_remote": "Remote",
"txt_log_trigger_scheduled": "Scheduled",
"txt_log_max_1000": "Up to 1,000 entries",
"txt_log_max_5000": "Up to 5,000 entries",
"txt_log_max_10000": "Up to 10,000 entries",
"txt_log_max_50000": "Up to 50,000 entries",
"txt_log_max_entries": "Storage cap",
"txt_log_max_unlimited": "Unlimited entries",
"txt_log_retention_7d": "Keep 7 days",
"txt_log_retention_30d": "Keep 30 days",
"txt_log_retention_90d": "Keep 90 days",
"txt_log_retention_180d": "Keep 180 days",
"txt_log_retention_365d": "Keep 365 days",
"txt_log_retention_days": "Retention",
"txt_log_retention_forever": "Keep forever",
"txt_log_retention_hint": "Automatically trims by age and entry count to reduce D1 storage use.",
"txt_log_retention_mode": "Retention mode",
"txt_log_retention_mode_days": "By time",
"txt_log_retention_mode_entries": "By count",
"txt_log_retention_settings": "Log retention",
"txt_log_settings": "Settings",
"txt_log_settings_save_failed": "Failed to save log settings",
"txt_log_settings_saved": "Log settings saved",
"txt_log_search_placeholder": "Search action, actor, target, request path, or metadata",
"txt_log_total": " total",
"txt_log_visible": " visible",
"txt_metadata": "Metadata",
"txt_no_logs_found": "No logs found",
"txt_no_metadata": "No metadata",
"txt_clear_all_logs": "Clear logs",
"txt_clear_logs_confirm": "Clear all logs? This cannot be undone.",
"txt_clear_logs_failed": "Failed to clear logs",
"txt_logs_cleared": "Logs cleared",
"txt_search": "Search",
"txt_target": "Target",
"txt_time": "Time",
"txt_time_range": "Time range",
"txt_remove_domain": "Remove domain" "txt_remove_domain": "Remove domain"
}; };
+193
View File
@@ -2,6 +2,7 @@
const es: Record<string, string> = { const es: Record<string, string> = {
"nav_account_settings": "Configuración de la cuenta", "nav_account_settings": "Configuración de la cuenta",
"nav_admin_panel": "Panel de administración", "nav_admin_panel": "Panel de administración",
"nav_log_center": "Centro de registros",
"nav_device_management": "Gestión de dispositivos", "nav_device_management": "Gestión de dispositivos",
"nav_my_vault": "Mi bóveda", "nav_my_vault": "Mi bóveda",
"nav_vault_items": "Bóveda", "nav_vault_items": "Bóveda",
@@ -368,6 +369,7 @@ const es: Record<string, string> = {
"txt_delete_item": "Eliminar elemento", "txt_delete_item": "Eliminar elemento",
"txt_delete_passkey": "Eliminar clave de acceso", "txt_delete_passkey": "Eliminar clave de acceso",
"txt_delete_item_failed": "Error al eliminar elemento", "txt_delete_item_failed": "Error al eliminar elemento",
"txt_permanent_delete_item_failed": "Error al eliminar elemento permanentemente",
"txt_delete_permanently": "Eliminar permanentemente", "txt_delete_permanently": "Eliminar permanentemente",
"txt_archive": "Archivar", "txt_archive": "Archivar",
"txt_archive_item": "Archivar elemento", "txt_archive_item": "Archivar elemento",
@@ -500,6 +502,7 @@ const es: Record<string, string> = {
"txt_item": "Elemento", "txt_item": "Elemento",
"txt_item_created": "Elemento creado", "txt_item_created": "Elemento creado",
"txt_item_deleted": "Elemento eliminado", "txt_item_deleted": "Elemento eliminado",
"txt_item_deleted_permanently": "Elemento eliminado permanentemente",
"txt_item_history": "Historial del elemento", "txt_item_history": "Historial del elemento",
"txt_password_history": "Historial de contraseñas", "txt_password_history": "Historial de contraseñas",
"txt_password_updated_value": "Contraseña actualizada: {value}", "txt_password_updated_value": "Contraseña actualizada: {value}",
@@ -690,6 +693,12 @@ const es: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "Error al revocar la confianza de todos los dispositivos", "txt_revoke_all_device_trust_failed": "Error al revocar la confianza de todos los dispositivos",
"txt_revoke_trust": "Revocar confianza", "txt_revoke_trust": "Revocar confianza",
"txt_untrust": "Quitar confianza", "txt_untrust": "Quitar confianza",
"txt_trust_permanently": "Confiar permanentemente",
"txt_trust_device_permanently": "Confiar permanentemente en el dispositivo",
"txt_trust_device_permanently_for_name": "¿Actualizar \"{name}\" de confianza de 30 días a confianza permanente?",
"txt_trust_device_permanently_failed": "Error al confiar permanentemente en el dispositivo",
"txt_device_trusted_permanently": "Dispositivo confiado permanentemente",
"txt_permanent_trust": "Confianza permanente",
"txt_update_device_note_failed": "Error al actualizar la nota del dispositivo", "txt_update_device_note_failed": "Error al actualizar la nota del dispositivo",
"txt_role": "Rol", "txt_role": "Rol",
"txt_save": "Guardar", "txt_save": "Guardar",
@@ -935,6 +944,190 @@ const es: Record<string, string> = {
"txt_nav_layout_grouped_expanded_desc": "Mantener todos los grupos abiertos", "txt_nav_layout_grouped_expanded_desc": "Mantener todos los grupos abiertos",
"txt_nav_layout_grouped_smart": "Grupos inteligentes", "txt_nav_layout_grouped_smart": "Grupos inteligentes",
"txt_nav_layout_grouped_smart_desc": "Abrir grupos activos cuando haga falta", "txt_nav_layout_grouped_smart_desc": "Abrir grupos activos cuando haga falta",
"txt_actor": "Actor",
"txt_all_levels": "Todos los niveles",
"txt_all_logs": "Todos los registros",
"txt_all_time": "Todo el tiempo",
"txt_audit_events": "Lista de registros",
"txt_filter": "Filtrar",
"txt_last_24_hours": "Últimas 24 horas",
"txt_last_7_days": "Últimos 7 días",
"txt_last_30_days": "Últimos 30 días",
"txt_load_logs_failed": "No se pudieron cargar los registros",
"txt_load_log_settings_failed": "No se pudo cargar la configuración de registros",
"txt_log_category": "Categoría",
"txt_log_category_auth": "Acceso y sesiones",
"txt_log_category_data": "Operaciones de datos",
"txt_log_category_device": "Dispositivos",
"txt_log_category_security": "Seguridad de cuenta",
"txt_log_category_system": "Sistema",
"txt_log_center_description": "Revisa inicios de sesión, fallos de renovación, eventos de dispositivos, cambios de seguridad, copias y acciones de administración.",
"txt_log_center_title": "Centro de registros",
"txt_log_level": "Nivel",
"txt_log_level_error": "Error",
"txt_log_level_info": "Info",
"txt_log_level_security": "Seguridad",
"txt_log_level_warn": "Aviso",
"txt_log_action_account_api_key_create": "Create API key",
"txt_log_action_account_api_key_rotate": "Rotate API key",
"txt_log_action_account_keys_update": "Update account keys",
"txt_log_action_account_profile_update": "Update account profile",
"txt_log_action_account_totp_disable": "Disable two-step login",
"txt_log_action_account_totp_enable": "Enable two-step login",
"txt_log_action_account_totp_recover": "Recover two-step login",
"txt_log_action_account_verify_devices_update": "Update device verification",
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
"txt_log_action_admin_backup_export": "Export backup",
"txt_log_action_admin_backup_import": "Import backup",
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status",
"txt_log_action_attachment_delete": "Delete attachment",
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
"txt_log_action_auth_login_success": "Login succeeded",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
"txt_log_action_device_deactivate": "Deactivate device",
"txt_log_action_device_delete": "Delete device",
"txt_log_action_device_delete_all": "Delete all devices",
"txt_log_action_device_name_update": "Update device name",
"txt_log_action_device_trust_permanent": "Trust device permanently",
"txt_log_action_device_trust_revoke": "Revoke device trust",
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
"txt_log_action_folder_delete": "Delete folder",
"txt_log_action_folder_delete_bulk": "Delete folders",
"txt_log_action_send_auth_remove": "Remove Send authentication",
"txt_log_action_send_delete": "Delete Send",
"txt_log_action_send_delete_bulk": "Delete Sends",
"txt_log_action_send_password_remove": "Remove Send password",
"txt_log_action_user_password_change": "Change master password",
"txt_log_action_user_register_first_admin": "Register first admin",
"txt_log_action_user_register_invite": "Register by invite",
"txt_log_meta_attachments": "Attachments",
"txt_log_meta_bytes": "Bytes",
"txt_log_meta_changed": "Changed fields",
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
"txt_log_meta_cipher_id": "Vault item ID",
"txt_log_meta_ciphers": "Vault items",
"txt_log_meta_compat": "Compatibility",
"txt_log_meta_compressed_bytes": "Compressed bytes",
"txt_log_meta_count": "Count",
"txt_log_meta_deleted": "Deleted count",
"txt_log_meta_destination_count": "Destination count",
"txt_log_meta_destination_id": "Destination ID",
"txt_log_meta_destination_name": "Destination name",
"txt_log_meta_destination_type": "Destination type",
"txt_log_meta_device_identifier": "Device ID",
"txt_log_meta_device_type": "Device type",
"txt_log_meta_email": "Email",
"txt_log_meta_error": "Error",
"txt_log_meta_expires_in_hours": "Expires in hours",
"txt_log_meta_file_bytes": "File bytes",
"txt_log_meta_file_name": "File name",
"txt_log_meta_folder_id": "Folder ID",
"txt_log_meta_grant_type": "Login method",
"txt_log_meta_includes_attachments": "Includes attachments",
"txt_log_meta_ip": "IP address",
"txt_log_meta_max_entries": "Entry limit",
"txt_log_meta_method": "Request method",
"txt_log_meta_path": "Request path",
"txt_log_meta_provider": "Provider",
"txt_log_meta_prune_error": "Cleanup error",
"txt_log_meta_pruned_file_count": "Cleaned files",
"txt_log_meta_raw": "Raw data",
"txt_log_meta_reason": "Reason",
"txt_log_meta_remote_path": "Remote path",
"txt_log_meta_removed": "Removed count",
"txt_log_meta_removed_devices": "Removed devices",
"txt_log_meta_removed_sessions": "Removed sessions",
"txt_log_meta_removed_trusted": "Trust removals",
"txt_log_meta_replace_existing": "Replace existing data",
"txt_log_meta_requested": "Requested count",
"txt_log_meta_requested_count": "Requested count",
"txt_log_meta_retention_days": "Retention days",
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
"txt_log_meta_size": "Size",
"txt_log_meta_skipped_attachments": "Skipped attachments",
"txt_log_meta_skipped_reason": "Skip reason",
"txt_log_meta_status": "Status",
"txt_log_meta_target_email": "Target email",
"txt_log_meta_trigger": "Trigger",
"txt_log_meta_type": "Type",
"txt_log_meta_updated": "Updated count",
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
"txt_log_meta_user_agent": "Browser/client",
"txt_log_meta_users": "Users",
"txt_log_meta_verify_devices": "Verify devices",
"txt_log_meta_web_session": "Web session",
"txt_log_reason_bad_api_key": "Bad API key",
"txt_log_reason_bad_password": "Bad password",
"txt_log_reason_device_missing": "Device missing",
"txt_log_reason_device_session_mismatch": "Device session mismatch",
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
"txt_log_reason_user_inactive": "User inactive",
"txt_log_reason_user_missing": "User missing",
"txt_log_target_type_attachment": "Attachment",
"txt_log_target_type_audit_log": "Log",
"txt_log_target_type_backup": "Backup",
"txt_log_target_type_cipher": "Vault item",
"txt_log_target_type_device": "Device",
"txt_log_target_type_folder": "Folder",
"txt_log_target_type_invite": "Invite",
"txt_log_target_type_refresh_token": "Refresh token",
"txt_log_target_type_send": "Send",
"txt_log_target_type_user": "User",
"txt_log_trigger_manual": "Manual",
"txt_log_trigger_remote": "Remote",
"txt_log_trigger_scheduled": "Scheduled",
"txt_log_max_1000": "Hasta 1000 entradas",
"txt_log_max_5000": "Hasta 5000 entradas",
"txt_log_max_10000": "Hasta 10 000 entradas",
"txt_log_max_50000": "Hasta 50 000 entradas",
"txt_log_max_entries": "Límite de almacenamiento",
"txt_log_max_unlimited": "Entradas ilimitadas",
"txt_log_retention_7d": "Conservar 7 días",
"txt_log_retention_30d": "Conservar 30 días",
"txt_log_retention_90d": "Conservar 90 días",
"txt_log_retention_180d": "Conservar 180 días",
"txt_log_retention_365d": "Conservar 365 días",
"txt_log_retention_days": "Retención",
"txt_log_retention_forever": "Conservar siempre",
"txt_log_retention_hint": "Recorta automáticamente por antigüedad y cantidad para reducir el uso de D1.",
"txt_log_retention_mode": "Modo de retención",
"txt_log_retention_mode_days": "Por tiempo",
"txt_log_retention_mode_entries": "Por cantidad",
"txt_log_retention_settings": "Retención de registros",
"txt_log_settings": "Configuración",
"txt_log_settings_save_failed": "No se pudo guardar la configuración de registros",
"txt_log_settings_saved": "Configuración de registros guardada",
"txt_log_search_placeholder": "Buscar acción, actor, destino, ruta o metadatos",
"txt_log_total": " total",
"txt_log_visible": " visibles",
"txt_metadata": "Metadatos",
"txt_no_logs_found": "No se encontraron registros",
"txt_no_metadata": "Sin metadatos",
"txt_clear_all_logs": "Borrar registros",
"txt_clear_logs_confirm": "¿Borrar todos los registros? Esta acción no se puede deshacer.",
"txt_clear_logs_failed": "No se pudieron borrar los registros",
"txt_logs_cleared": "Registros borrados",
"txt_search": "Buscar",
"txt_target": "Destino",
"txt_time": "Hora",
"txt_time_range": "Rango de tiempo",
"txt_remove_domain": "Quitar dominio" "txt_remove_domain": "Quitar dominio"
}; };
+193
View File
@@ -3,6 +3,7 @@ const ru: Record<string, string> = {
"txt_backup_destination_detail_note": "", "txt_backup_destination_detail_note": "",
"nav_account_settings": "Настройки учетной записи", "nav_account_settings": "Настройки учетной записи",
"nav_admin_panel": "Панель администратора", "nav_admin_panel": "Панель администратора",
"nav_log_center": "Центр журналов",
"nav_device_management": "Управление устройствами", "nav_device_management": "Управление устройствами",
"nav_my_vault": "Мое хранилище", "nav_my_vault": "Мое хранилище",
"nav_vault_items": "Хранилище", "nav_vault_items": "Хранилище",
@@ -368,6 +369,7 @@ const ru: Record<string, string> = {
"txt_delete_item": "Удалить элемент", "txt_delete_item": "Удалить элемент",
"txt_delete_passkey": "Удалить пароль", "txt_delete_passkey": "Удалить пароль",
"txt_delete_item_failed": "Удалить элемент не удалось", "txt_delete_item_failed": "Удалить элемент не удалось",
"txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент",
"txt_delete_permanently": "Удалить навсегда", "txt_delete_permanently": "Удалить навсегда",
"txt_archive": "Архив", "txt_archive": "Архив",
"txt_archive_item": "Архивный элемент", "txt_archive_item": "Архивный элемент",
@@ -500,6 +502,7 @@ const ru: Record<string, string> = {
"txt_item": "Товар", "txt_item": "Товар",
"txt_item_created": "Объект создан", "txt_item_created": "Объект создан",
"txt_item_deleted": "Объект удален.", "txt_item_deleted": "Объект удален.",
"txt_item_deleted_permanently": "Объект окончательно удален.",
"txt_item_history": "История предмета", "txt_item_history": "История предмета",
"txt_password_history": "История паролей", "txt_password_history": "История паролей",
"txt_password_updated_value": "Пароль обновлен: {value}", "txt_password_updated_value": "Пароль обновлен: {value}",
@@ -690,6 +693,12 @@ const ru: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "Не удалось отозвать все доверие устройств.", "txt_revoke_all_device_trust_failed": "Не удалось отозвать все доверие устройств.",
"txt_revoke_trust": "Отозвать доверие", "txt_revoke_trust": "Отозвать доверие",
"txt_untrust": "Не доверять", "txt_untrust": "Не доверять",
"txt_trust_permanently": "Доверять постоянно",
"txt_trust_device_permanently": "Постоянно доверять устройству",
"txt_trust_device_permanently_for_name": "Повысить доверие к «{name}» с 30 дней до постоянного?",
"txt_trust_device_permanently_failed": "Не удалось постоянно доверять устройству.",
"txt_device_trusted_permanently": "Устройство постоянно доверено",
"txt_permanent_trust": "Постоянное доверие",
"txt_update_device_note_failed": "Не удалось обновить примечание об устройстве.", "txt_update_device_note_failed": "Не удалось обновить примечание об устройстве.",
"txt_role": "Роль", "txt_role": "Роль",
"txt_save": "Сохранить", "txt_save": "Сохранить",
@@ -935,6 +944,190 @@ const ru: Record<string, string> = {
"txt_nav_layout_grouped_expanded_desc": "Держать все группы открытыми", "txt_nav_layout_grouped_expanded_desc": "Держать все группы открытыми",
"txt_nav_layout_grouped_smart": "Умные группы", "txt_nav_layout_grouped_smart": "Умные группы",
"txt_nav_layout_grouped_smart_desc": "Открывать активные группы по необходимости", "txt_nav_layout_grouped_smart_desc": "Открывать активные группы по необходимости",
"txt_actor": "Инициатор",
"txt_all_levels": "Все уровни",
"txt_all_logs": "Все журналы",
"txt_all_time": "Все время",
"txt_audit_events": "Список журналов",
"txt_filter": "Фильтр",
"txt_last_24_hours": "Последние 24 часа",
"txt_last_7_days": "Последние 7 дней",
"txt_last_30_days": "Последние 30 дней",
"txt_load_logs_failed": "Не удалось загрузить журналы",
"txt_load_log_settings_failed": "Не удалось загрузить настройки журналов",
"txt_log_category": "Категория",
"txt_log_category_auth": "Вход и сессии",
"txt_log_category_data": "Операции с данными",
"txt_log_category_device": "Устройства",
"txt_log_category_security": "Безопасность учетной записи",
"txt_log_category_system": "Система",
"txt_log_center_description": "Просматривайте входы, сбои обновления, события устройств, изменения безопасности, резервные копии и действия администратора.",
"txt_log_center_title": "Центр журналов",
"txt_log_level": "Уровень",
"txt_log_level_error": "Ошибка",
"txt_log_level_info": "Инфо",
"txt_log_level_security": "Безопасность",
"txt_log_level_warn": "Предупреждение",
"txt_log_action_account_api_key_create": "Create API key",
"txt_log_action_account_api_key_rotate": "Rotate API key",
"txt_log_action_account_keys_update": "Update account keys",
"txt_log_action_account_profile_update": "Update account profile",
"txt_log_action_account_totp_disable": "Disable two-step login",
"txt_log_action_account_totp_enable": "Enable two-step login",
"txt_log_action_account_totp_recover": "Recover two-step login",
"txt_log_action_account_verify_devices_update": "Update device verification",
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
"txt_log_action_admin_backup_export": "Export backup",
"txt_log_action_admin_backup_import": "Import backup",
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status",
"txt_log_action_attachment_delete": "Delete attachment",
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
"txt_log_action_auth_login_success": "Login succeeded",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
"txt_log_action_device_deactivate": "Deactivate device",
"txt_log_action_device_delete": "Delete device",
"txt_log_action_device_delete_all": "Delete all devices",
"txt_log_action_device_name_update": "Update device name",
"txt_log_action_device_trust_permanent": "Trust device permanently",
"txt_log_action_device_trust_revoke": "Revoke device trust",
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
"txt_log_action_folder_delete": "Delete folder",
"txt_log_action_folder_delete_bulk": "Delete folders",
"txt_log_action_send_auth_remove": "Remove Send authentication",
"txt_log_action_send_delete": "Delete Send",
"txt_log_action_send_delete_bulk": "Delete Sends",
"txt_log_action_send_password_remove": "Remove Send password",
"txt_log_action_user_password_change": "Change master password",
"txt_log_action_user_register_first_admin": "Register first admin",
"txt_log_action_user_register_invite": "Register by invite",
"txt_log_meta_attachments": "Attachments",
"txt_log_meta_bytes": "Bytes",
"txt_log_meta_changed": "Changed fields",
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
"txt_log_meta_cipher_id": "Vault item ID",
"txt_log_meta_ciphers": "Vault items",
"txt_log_meta_compat": "Compatibility",
"txt_log_meta_compressed_bytes": "Compressed bytes",
"txt_log_meta_count": "Count",
"txt_log_meta_deleted": "Deleted count",
"txt_log_meta_destination_count": "Destination count",
"txt_log_meta_destination_id": "Destination ID",
"txt_log_meta_destination_name": "Destination name",
"txt_log_meta_destination_type": "Destination type",
"txt_log_meta_device_identifier": "Device ID",
"txt_log_meta_device_type": "Device type",
"txt_log_meta_email": "Email",
"txt_log_meta_error": "Error",
"txt_log_meta_expires_in_hours": "Expires in hours",
"txt_log_meta_file_bytes": "File bytes",
"txt_log_meta_file_name": "File name",
"txt_log_meta_folder_id": "Folder ID",
"txt_log_meta_grant_type": "Login method",
"txt_log_meta_includes_attachments": "Includes attachments",
"txt_log_meta_ip": "IP address",
"txt_log_meta_max_entries": "Entry limit",
"txt_log_meta_method": "Request method",
"txt_log_meta_path": "Request path",
"txt_log_meta_provider": "Provider",
"txt_log_meta_prune_error": "Cleanup error",
"txt_log_meta_pruned_file_count": "Cleaned files",
"txt_log_meta_raw": "Raw data",
"txt_log_meta_reason": "Reason",
"txt_log_meta_remote_path": "Remote path",
"txt_log_meta_removed": "Removed count",
"txt_log_meta_removed_devices": "Removed devices",
"txt_log_meta_removed_sessions": "Removed sessions",
"txt_log_meta_removed_trusted": "Trust removals",
"txt_log_meta_replace_existing": "Replace existing data",
"txt_log_meta_requested": "Requested count",
"txt_log_meta_requested_count": "Requested count",
"txt_log_meta_retention_days": "Retention days",
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
"txt_log_meta_size": "Size",
"txt_log_meta_skipped_attachments": "Skipped attachments",
"txt_log_meta_skipped_reason": "Skip reason",
"txt_log_meta_status": "Status",
"txt_log_meta_target_email": "Target email",
"txt_log_meta_trigger": "Trigger",
"txt_log_meta_type": "Type",
"txt_log_meta_updated": "Updated count",
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
"txt_log_meta_user_agent": "Browser/client",
"txt_log_meta_users": "Users",
"txt_log_meta_verify_devices": "Verify devices",
"txt_log_meta_web_session": "Web session",
"txt_log_reason_bad_api_key": "Bad API key",
"txt_log_reason_bad_password": "Bad password",
"txt_log_reason_device_missing": "Device missing",
"txt_log_reason_device_session_mismatch": "Device session mismatch",
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
"txt_log_reason_user_inactive": "User inactive",
"txt_log_reason_user_missing": "User missing",
"txt_log_target_type_attachment": "Attachment",
"txt_log_target_type_audit_log": "Log",
"txt_log_target_type_backup": "Backup",
"txt_log_target_type_cipher": "Vault item",
"txt_log_target_type_device": "Device",
"txt_log_target_type_folder": "Folder",
"txt_log_target_type_invite": "Invite",
"txt_log_target_type_refresh_token": "Refresh token",
"txt_log_target_type_send": "Send",
"txt_log_target_type_user": "User",
"txt_log_trigger_manual": "Manual",
"txt_log_trigger_remote": "Remote",
"txt_log_trigger_scheduled": "Scheduled",
"txt_log_max_1000": "До 1 000 записей",
"txt_log_max_5000": "До 5 000 записей",
"txt_log_max_10000": "До 10 000 записей",
"txt_log_max_50000": "До 50 000 записей",
"txt_log_max_entries": "Лимит хранения",
"txt_log_max_unlimited": "Без ограничения записей",
"txt_log_retention_7d": "Хранить 7 дней",
"txt_log_retention_30d": "Хранить 30 дней",
"txt_log_retention_90d": "Хранить 90 дней",
"txt_log_retention_180d": "Хранить 180 дней",
"txt_log_retention_365d": "Хранить 365 дней",
"txt_log_retention_days": "Срок хранения",
"txt_log_retention_forever": "Хранить всегда",
"txt_log_retention_hint": "Автоматически обрезает по возрасту и количеству, чтобы уменьшить использование D1.",
"txt_log_retention_mode": "Режим хранения",
"txt_log_retention_mode_days": "По времени",
"txt_log_retention_mode_entries": "По количеству",
"txt_log_retention_settings": "Хранение журналов",
"txt_log_settings": "Настройки",
"txt_log_settings_save_failed": "Не удалось сохранить настройки журналов",
"txt_log_settings_saved": "Настройки журналов сохранены",
"txt_log_search_placeholder": "Поиск действия, инициатора, цели, пути или метаданных",
"txt_log_total": " всего",
"txt_log_visible": " показано",
"txt_metadata": "Метаданные",
"txt_no_logs_found": "Журналы не найдены",
"txt_no_metadata": "Нет метаданных",
"txt_clear_all_logs": "Очистить журналы",
"txt_clear_logs_confirm": "Очистить все журналы? Это действие нельзя отменить.",
"txt_clear_logs_failed": "Не удалось очистить журналы",
"txt_logs_cleared": "Журналы очищены",
"txt_search": "Поиск",
"txt_target": "Цель",
"txt_time": "Время",
"txt_time_range": "Период",
"txt_remove_domain": "Удалить домен" "txt_remove_domain": "Удалить домен"
}; };
+193
View File
@@ -2,6 +2,7 @@
const zhCN: Record<string, string> = { const zhCN: Record<string, string> = {
"nav_account_settings": "账户设置", "nav_account_settings": "账户设置",
"nav_admin_panel": "用户管理", "nav_admin_panel": "用户管理",
"nav_log_center": "日志中心",
"nav_device_management": "设备管理", "nav_device_management": "设备管理",
"nav_my_vault": "我的密码库", "nav_my_vault": "我的密码库",
"nav_vault_items": "密码库", "nav_vault_items": "密码库",
@@ -368,6 +369,7 @@ const zhCN: Record<string, string> = {
"txt_delete_item": "删除项目", "txt_delete_item": "删除项目",
"txt_delete_passkey": "删除通行密钥", "txt_delete_passkey": "删除通行密钥",
"txt_delete_item_failed": "删除项目失败", "txt_delete_item_failed": "删除项目失败",
"txt_permanent_delete_item_failed": "永久删除项目失败",
"txt_delete_permanently": "永久删除", "txt_delete_permanently": "永久删除",
"txt_archive": "归档", "txt_archive": "归档",
"txt_archive_item": "归档项目", "txt_archive_item": "归档项目",
@@ -500,6 +502,7 @@ const zhCN: Record<string, string> = {
"txt_item": "项目", "txt_item": "项目",
"txt_item_created": "项目已创建", "txt_item_created": "项目已创建",
"txt_item_deleted": "项目已删除", "txt_item_deleted": "项目已删除",
"txt_item_deleted_permanently": "项目已永久删除",
"txt_item_history": "项目历史", "txt_item_history": "项目历史",
"txt_password_history": "密码历史记录", "txt_password_history": "密码历史记录",
"txt_password_updated_value": "密码更新于: {value}", "txt_password_updated_value": "密码更新于: {value}",
@@ -690,6 +693,12 @@ const zhCN: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "撤销所有设备信任失败", "txt_revoke_all_device_trust_failed": "撤销所有设备信任失败",
"txt_revoke_trust": "撤销信任", "txt_revoke_trust": "撤销信任",
"txt_untrust": "不信任", "txt_untrust": "不信任",
"txt_trust_permanently": "永久信任",
"txt_trust_device_permanently": "永久信任设备",
"txt_trust_device_permanently_for_name": "确认把“{name}”从 30 天信任升级为永久信任吗?",
"txt_trust_device_permanently_failed": "永久信任设备失败",
"txt_device_trusted_permanently": "设备已永久信任",
"txt_permanent_trust": "永久信任",
"txt_update_device_note_failed": "更新设备备注失败", "txt_update_device_note_failed": "更新设备备注失败",
"txt_role": "角色", "txt_role": "角色",
"txt_save": "保存", "txt_save": "保存",
@@ -935,6 +944,190 @@ const zhCN: Record<string, string> = {
"txt_nav_layout_grouped_expanded_desc": "父子菜单全部展开", "txt_nav_layout_grouped_expanded_desc": "父子菜单全部展开",
"txt_nav_layout_grouped_smart": "智能分组", "txt_nav_layout_grouped_smart": "智能分组",
"txt_nav_layout_grouped_smart_desc": "当前相关分组自动展开", "txt_nav_layout_grouped_smart_desc": "当前相关分组自动展开",
"txt_actor": "操作者",
"txt_all_levels": "全部级别",
"txt_all_logs": "全部日志",
"txt_all_time": "全部时间",
"txt_audit_events": "日志列表",
"txt_filter": "筛选",
"txt_last_24_hours": "最近 24 小时",
"txt_last_7_days": "最近 7 天",
"txt_last_30_days": "最近 30 天",
"txt_load_logs_failed": "加载日志失败",
"txt_load_log_settings_failed": "加载日志设置失败",
"txt_log_category": "分类",
"txt_log_category_auth": "登录与会话",
"txt_log_category_data": "数据操作",
"txt_log_category_device": "设备",
"txt_log_category_security": "账户安全",
"txt_log_category_system": "系统",
"txt_log_center_description": "查看登录、刷新失败、设备事件、安全变更、备份操作和管理员操作。",
"txt_log_center_title": "日志中心",
"txt_log_level": "级别",
"txt_log_level_error": "错误",
"txt_log_level_info": "信息",
"txt_log_level_security": "安全",
"txt_log_level_warn": "警告",
"txt_log_action_account_api_key_create": "创建 API 密钥",
"txt_log_action_account_api_key_rotate": "轮换 API 密钥",
"txt_log_action_account_keys_update": "更新账户密钥",
"txt_log_action_account_profile_update": "更新账户资料",
"txt_log_action_account_totp_disable": "关闭两步验证",
"txt_log_action_account_totp_enable": "开启两步验证",
"txt_log_action_account_totp_recover": "恢复两步验证",
"txt_log_action_account_verify_devices_update": "更新设备验证设置",
"txt_log_action_admin_audit_settings_update": "更新日志保留设置",
"txt_log_action_admin_backup_export": "导出备份",
"txt_log_action_admin_backup_import": "导入备份",
"txt_log_action_admin_backup_remote_delete": "删除远程备份",
"txt_log_action_admin_backup_remote_manual": "手动远程备份成功",
"txt_log_action_admin_backup_remote_manual_failed": "手动远程备份失败",
"txt_log_action_admin_backup_remote_scheduled": "计划远程备份成功",
"txt_log_action_admin_backup_remote_scheduled_failed": "计划远程备份失败",
"txt_log_action_admin_backup_settings_repair": "修复备份设置",
"txt_log_action_admin_backup_settings_update": "更新备份设置",
"txt_log_action_admin_invite_create": "创建邀请",
"txt_log_action_admin_invite_delete_all": "清空邀请",
"txt_log_action_admin_invite_revoke": "撤销邀请",
"txt_log_action_admin_user_delete": "删除用户",
"txt_log_action_admin_user_status": "修改用户状态",
"txt_log_action_attachment_delete": "删除附件",
"txt_log_action_auth_login_failed_bad_api_key": "API 密钥错误登录失败",
"txt_log_action_auth_login_failed_bad_password": "密码错误登录失败",
"txt_log_action_auth_login_failed_user_inactive": "账号停用登录失败",
"txt_log_action_auth_login_success": "登录成功",
"txt_log_action_auth_refresh_failed": "刷新登录失败:{reason}",
"txt_log_action_cipher_delete_permanent": "永久删除密码项",
"txt_log_action_cipher_delete_permanent_bulk": "批量永久删除密码项",
"txt_log_action_cipher_delete_soft": "删除到回收站",
"txt_log_action_cipher_delete_soft_bulk": "批量删除到回收站",
"txt_log_action_device_deactivate": "停用设备",
"txt_log_action_device_delete": "删除设备",
"txt_log_action_device_delete_all": "删除全部设备",
"txt_log_action_device_name_update": "修改设备名称",
"txt_log_action_device_trust_permanent": "永久信任设备",
"txt_log_action_device_trust_revoke": "撤销设备信任",
"txt_log_action_device_trust_revoke_batch": "批量撤销设备信任",
"txt_log_action_folder_delete": "删除文件夹",
"txt_log_action_folder_delete_bulk": "批量删除文件夹",
"txt_log_action_send_auth_remove": "移除 Send 验证",
"txt_log_action_send_delete": "删除 Send",
"txt_log_action_send_delete_bulk": "批量删除 Send",
"txt_log_action_send_password_remove": "移除 Send 密码",
"txt_log_action_user_password_change": "修改主密码",
"txt_log_action_user_register_first_admin": "注册首个管理员",
"txt_log_action_user_register_invite": "通过邀请注册",
"txt_log_meta_attachments": "附件数",
"txt_log_meta_bytes": "字节数",
"txt_log_meta_changed": "变更项",
"txt_log_meta_checksum_mismatch_accepted": "已接受校验不一致",
"txt_log_meta_cipher_id": "密码项 ID",
"txt_log_meta_ciphers": "密码项数量",
"txt_log_meta_compat": "兼容信息",
"txt_log_meta_compressed_bytes": "压缩后字节数",
"txt_log_meta_count": "数量",
"txt_log_meta_deleted": "已删除数量",
"txt_log_meta_destination_count": "备份目标数量",
"txt_log_meta_destination_id": "备份目标 ID",
"txt_log_meta_destination_name": "备份目标名称",
"txt_log_meta_destination_type": "备份目标类型",
"txt_log_meta_device_identifier": "设备 ID",
"txt_log_meta_device_type": "设备类型",
"txt_log_meta_email": "邮箱",
"txt_log_meta_error": "错误",
"txt_log_meta_expires_in_hours": "过期小时数",
"txt_log_meta_file_bytes": "文件字节数",
"txt_log_meta_file_name": "文件名",
"txt_log_meta_folder_id": "文件夹 ID",
"txt_log_meta_grant_type": "登录方式",
"txt_log_meta_includes_attachments": "包含附件",
"txt_log_meta_ip": "IP 地址",
"txt_log_meta_max_entries": "条数上限",
"txt_log_meta_method": "请求方法",
"txt_log_meta_path": "请求路径",
"txt_log_meta_provider": "服务提供方",
"txt_log_meta_prune_error": "清理错误",
"txt_log_meta_pruned_file_count": "已清理文件数",
"txt_log_meta_raw": "原始数据",
"txt_log_meta_reason": "原因",
"txt_log_meta_remote_path": "远程路径",
"txt_log_meta_removed": "已移除数量",
"txt_log_meta_removed_devices": "已移除设备数",
"txt_log_meta_removed_sessions": "已移除会话数",
"txt_log_meta_removed_trusted": "已撤销信任数",
"txt_log_meta_replace_existing": "覆盖现有数据",
"txt_log_meta_requested": "请求数量",
"txt_log_meta_requested_count": "请求数量",
"txt_log_meta_retention_days": "保留天数",
"txt_log_meta_scheduled_destination_count": "已计划备份目标数",
"txt_log_meta_size": "大小",
"txt_log_meta_skipped_attachments": "跳过附件数",
"txt_log_meta_skipped_reason": "跳过原因",
"txt_log_meta_status": "状态",
"txt_log_meta_target_email": "目标邮箱",
"txt_log_meta_trigger": "触发方式",
"txt_log_meta_type": "类型",
"txt_log_meta_updated": "已更新数量",
"txt_log_meta_upload_verification_attempts": "上传校验次数",
"txt_log_meta_user_agent": "浏览器/客户端",
"txt_log_meta_users": "用户数量",
"txt_log_meta_verify_devices": "验证设备",
"txt_log_meta_web_session": "网页会话",
"txt_log_reason_bad_api_key": "API 密钥错误",
"txt_log_reason_bad_password": "密码错误",
"txt_log_reason_device_missing": "设备不存在",
"txt_log_reason_device_session_mismatch": "设备会话不匹配",
"txt_log_reason_token_not_found_or_expired": "令牌不存在或已过期",
"txt_log_reason_user_inactive": "用户未启用",
"txt_log_reason_user_missing": "用户不存在",
"txt_log_target_type_attachment": "附件",
"txt_log_target_type_audit_log": "日志",
"txt_log_target_type_backup": "备份",
"txt_log_target_type_cipher": "密码项",
"txt_log_target_type_device": "设备",
"txt_log_target_type_folder": "文件夹",
"txt_log_target_type_invite": "邀请",
"txt_log_target_type_refresh_token": "刷新令牌",
"txt_log_target_type_send": "Send",
"txt_log_target_type_user": "用户",
"txt_log_trigger_manual": "手动",
"txt_log_trigger_remote": "远程",
"txt_log_trigger_scheduled": "计划任务",
"txt_log_max_1000": "最多 1,000 条",
"txt_log_max_5000": "最多 5,000 条",
"txt_log_max_10000": "最多 10,000 条",
"txt_log_max_50000": "最多 50,000 条",
"txt_log_max_entries": "容量上限",
"txt_log_max_unlimited": "不限制条数",
"txt_log_retention_7d": "保留 7 天",
"txt_log_retention_30d": "保留 30 天",
"txt_log_retention_90d": "保留 90 天",
"txt_log_retention_180d": "保留 180 天",
"txt_log_retention_365d": "保留 365 天",
"txt_log_retention_days": "保留时间",
"txt_log_retention_forever": "永久保留",
"txt_log_retention_hint": "按时间和最大条数自动收缩,减少 D1 存储占用。",
"txt_log_retention_mode": "保留方式",
"txt_log_retention_mode_days": "按时间",
"txt_log_retention_mode_entries": "按条数",
"txt_log_retention_settings": "日志保留",
"txt_log_settings": "设置",
"txt_log_settings_save_failed": "保存日志设置失败",
"txt_log_settings_saved": "日志设置已保存",
"txt_log_search_placeholder": "搜索动作、操作者、目标、请求路径或元数据",
"txt_log_total": " 条总数",
"txt_log_visible": " 条显示",
"txt_metadata": "元数据",
"txt_no_logs_found": "没有找到日志",
"txt_no_metadata": "没有元数据",
"txt_clear_all_logs": "清空日志",
"txt_clear_logs_confirm": "确定清空全部日志吗?此操作无法撤销。",
"txt_clear_logs_failed": "清空日志失败",
"txt_logs_cleared": "日志已清空",
"txt_search": "搜索",
"txt_target": "目标",
"txt_time": "时间",
"txt_time_range": "时间范围",
"txt_remove_domain": "移除域名" "txt_remove_domain": "移除域名"
}; };
+193
View File
@@ -2,6 +2,7 @@
const zhTW: Record<string, string> = { const zhTW: Record<string, string> = {
"nav_account_settings": "賬戶設置", "nav_account_settings": "賬戶設置",
"nav_admin_panel": "用戶管理", "nav_admin_panel": "用戶管理",
"nav_log_center": "日誌中心",
"nav_device_management": "設備管理", "nav_device_management": "設備管理",
"nav_my_vault": "我的密碼庫", "nav_my_vault": "我的密碼庫",
"nav_vault_items": "密碼庫", "nav_vault_items": "密碼庫",
@@ -368,6 +369,7 @@ const zhTW: Record<string, string> = {
"txt_delete_item": "刪除項目", "txt_delete_item": "刪除項目",
"txt_delete_passkey": "刪除通行密鑰", "txt_delete_passkey": "刪除通行密鑰",
"txt_delete_item_failed": "刪除項目失敗", "txt_delete_item_failed": "刪除項目失敗",
"txt_permanent_delete_item_failed": "永久刪除項目失敗",
"txt_delete_permanently": "永久刪除", "txt_delete_permanently": "永久刪除",
"txt_archive": "歸檔", "txt_archive": "歸檔",
"txt_archive_item": "歸檔項目", "txt_archive_item": "歸檔項目",
@@ -500,6 +502,7 @@ const zhTW: Record<string, string> = {
"txt_item": "項目", "txt_item": "項目",
"txt_item_created": "項目已創建", "txt_item_created": "項目已創建",
"txt_item_deleted": "項目已刪除", "txt_item_deleted": "項目已刪除",
"txt_item_deleted_permanently": "項目已永久刪除",
"txt_item_history": "項目歷史", "txt_item_history": "項目歷史",
"txt_password_history": "密碼歷史記錄", "txt_password_history": "密碼歷史記錄",
"txt_password_updated_value": "密碼更新新於: {value}", "txt_password_updated_value": "密碼更新新於: {value}",
@@ -690,6 +693,12 @@ const zhTW: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "撤銷所有設備信任失敗", "txt_revoke_all_device_trust_failed": "撤銷所有設備信任失敗",
"txt_revoke_trust": "撤銷信任", "txt_revoke_trust": "撤銷信任",
"txt_untrust": "不信任", "txt_untrust": "不信任",
"txt_trust_permanently": "永久信任",
"txt_trust_device_permanently": "永久信任設備",
"txt_trust_device_permanently_for_name": "確認把“{name}”從 30 天信任升級為永久信任嗎?",
"txt_trust_device_permanently_failed": "永久信任設備失敗",
"txt_device_trusted_permanently": "設備已永久信任",
"txt_permanent_trust": "永久信任",
"txt_update_device_note_failed": "更新設備備註失敗", "txt_update_device_note_failed": "更新設備備註失敗",
"txt_role": "角色", "txt_role": "角色",
"txt_save": "保存", "txt_save": "保存",
@@ -935,6 +944,190 @@ const zhTW: Record<string, string> = {
"txt_nav_layout_grouped_expanded_desc": "父子選單全部展開", "txt_nav_layout_grouped_expanded_desc": "父子選單全部展開",
"txt_nav_layout_grouped_smart": "智能分組", "txt_nav_layout_grouped_smart": "智能分組",
"txt_nav_layout_grouped_smart_desc": "目前相關分組自動展開", "txt_nav_layout_grouped_smart_desc": "目前相關分組自動展開",
"txt_actor": "操作者",
"txt_all_levels": "全部級別",
"txt_all_logs": "全部日誌",
"txt_all_time": "全部時間",
"txt_audit_events": "日誌列表",
"txt_filter": "篩選",
"txt_last_24_hours": "最近 24 小時",
"txt_last_7_days": "最近 7 天",
"txt_last_30_days": "最近 30 天",
"txt_load_logs_failed": "載入日誌失敗",
"txt_load_log_settings_failed": "載入日誌設定失敗",
"txt_log_category": "分類",
"txt_log_category_auth": "登入與會話",
"txt_log_category_data": "資料操作",
"txt_log_category_device": "設備",
"txt_log_category_security": "賬戶安全",
"txt_log_category_system": "系統",
"txt_log_center_description": "查看登入、刷新失敗、設備事件、安全變更、備份操作和管理員操作。",
"txt_log_center_title": "日誌中心",
"txt_log_level": "級別",
"txt_log_level_error": "錯誤",
"txt_log_level_info": "資訊",
"txt_log_level_security": "安全",
"txt_log_level_warn": "警告",
"txt_log_action_account_api_key_create": "建立 API 金鑰",
"txt_log_action_account_api_key_rotate": "輪換 API 金鑰",
"txt_log_action_account_keys_update": "更新帳戶金鑰",
"txt_log_action_account_profile_update": "更新帳戶資料",
"txt_log_action_account_totp_disable": "關閉兩步驟登入",
"txt_log_action_account_totp_enable": "開啟兩步驟登入",
"txt_log_action_account_totp_recover": "復原兩步驟登入",
"txt_log_action_account_verify_devices_update": "更新裝置驗證設定",
"txt_log_action_admin_audit_settings_update": "更新日誌保留設定",
"txt_log_action_admin_backup_export": "匯出備份",
"txt_log_action_admin_backup_import": "匯入備份",
"txt_log_action_admin_backup_remote_delete": "刪除遠端備份",
"txt_log_action_admin_backup_remote_manual": "手動遠端備份成功",
"txt_log_action_admin_backup_remote_manual_failed": "手動遠端備份失敗",
"txt_log_action_admin_backup_remote_scheduled": "排程遠端備份成功",
"txt_log_action_admin_backup_remote_scheduled_failed": "排程遠端備份失敗",
"txt_log_action_admin_backup_settings_repair": "修復備份設定",
"txt_log_action_admin_backup_settings_update": "更新備份設定",
"txt_log_action_admin_invite_create": "建立邀請",
"txt_log_action_admin_invite_delete_all": "清空邀請",
"txt_log_action_admin_invite_revoke": "撤銷邀請",
"txt_log_action_admin_user_delete": "刪除使用者",
"txt_log_action_admin_user_status": "修改使用者狀態",
"txt_log_action_attachment_delete": "刪除附件",
"txt_log_action_auth_login_failed_bad_api_key": "API 金鑰錯誤登入失敗",
"txt_log_action_auth_login_failed_bad_password": "密碼錯誤登入失敗",
"txt_log_action_auth_login_failed_user_inactive": "帳號停用登入失敗",
"txt_log_action_auth_login_success": "登入成功",
"txt_log_action_auth_refresh_failed": "刷新登入失敗:{reason}",
"txt_log_action_cipher_delete_permanent": "永久刪除密碼項",
"txt_log_action_cipher_delete_permanent_bulk": "批次永久刪除密碼項",
"txt_log_action_cipher_delete_soft": "刪除到回收桶",
"txt_log_action_cipher_delete_soft_bulk": "批次刪除到回收桶",
"txt_log_action_device_deactivate": "停用裝置",
"txt_log_action_device_delete": "刪除裝置",
"txt_log_action_device_delete_all": "刪除全部裝置",
"txt_log_action_device_name_update": "修改裝置名稱",
"txt_log_action_device_trust_permanent": "永久信任裝置",
"txt_log_action_device_trust_revoke": "撤銷裝置信任",
"txt_log_action_device_trust_revoke_batch": "批次撤銷裝置信任",
"txt_log_action_folder_delete": "刪除資料夾",
"txt_log_action_folder_delete_bulk": "批次刪除資料夾",
"txt_log_action_send_auth_remove": "移除 Send 驗證",
"txt_log_action_send_delete": "刪除 Send",
"txt_log_action_send_delete_bulk": "批次刪除 Send",
"txt_log_action_send_password_remove": "移除 Send 密碼",
"txt_log_action_user_password_change": "修改主密碼",
"txt_log_action_user_register_first_admin": "註冊首個管理員",
"txt_log_action_user_register_invite": "透過邀請註冊",
"txt_log_meta_attachments": "附件數",
"txt_log_meta_bytes": "位元組數",
"txt_log_meta_changed": "變更項",
"txt_log_meta_checksum_mismatch_accepted": "已接受校驗不一致",
"txt_log_meta_cipher_id": "密碼項 ID",
"txt_log_meta_ciphers": "密碼項數量",
"txt_log_meta_compat": "相容資訊",
"txt_log_meta_compressed_bytes": "壓縮後位元組數",
"txt_log_meta_count": "數量",
"txt_log_meta_deleted": "已刪除數量",
"txt_log_meta_destination_count": "備份目標數量",
"txt_log_meta_destination_id": "備份目標 ID",
"txt_log_meta_destination_name": "備份目標名稱",
"txt_log_meta_destination_type": "備份目標類型",
"txt_log_meta_device_identifier": "裝置 ID",
"txt_log_meta_device_type": "裝置類型",
"txt_log_meta_email": "信箱",
"txt_log_meta_error": "錯誤",
"txt_log_meta_expires_in_hours": "過期小時數",
"txt_log_meta_file_bytes": "檔案位元組數",
"txt_log_meta_file_name": "檔案名稱",
"txt_log_meta_folder_id": "資料夾 ID",
"txt_log_meta_grant_type": "登入方式",
"txt_log_meta_includes_attachments": "包含附件",
"txt_log_meta_ip": "IP 位址",
"txt_log_meta_max_entries": "筆數上限",
"txt_log_meta_method": "請求方法",
"txt_log_meta_path": "請求路徑",
"txt_log_meta_provider": "服務提供方",
"txt_log_meta_prune_error": "清理錯誤",
"txt_log_meta_pruned_file_count": "已清理檔案數",
"txt_log_meta_raw": "原始資料",
"txt_log_meta_reason": "原因",
"txt_log_meta_remote_path": "遠端路徑",
"txt_log_meta_removed": "已移除數量",
"txt_log_meta_removed_devices": "已移除裝置數",
"txt_log_meta_removed_sessions": "已移除工作階段數",
"txt_log_meta_removed_trusted": "已撤銷信任數",
"txt_log_meta_replace_existing": "覆蓋現有資料",
"txt_log_meta_requested": "請求數量",
"txt_log_meta_requested_count": "請求數量",
"txt_log_meta_retention_days": "保留天數",
"txt_log_meta_scheduled_destination_count": "已排程備份目標數",
"txt_log_meta_size": "大小",
"txt_log_meta_skipped_attachments": "略過附件數",
"txt_log_meta_skipped_reason": "略過原因",
"txt_log_meta_status": "狀態",
"txt_log_meta_target_email": "目標信箱",
"txt_log_meta_trigger": "觸發方式",
"txt_log_meta_type": "類型",
"txt_log_meta_updated": "已更新數量",
"txt_log_meta_upload_verification_attempts": "上傳校驗次數",
"txt_log_meta_user_agent": "瀏覽器/用戶端",
"txt_log_meta_users": "使用者數量",
"txt_log_meta_verify_devices": "驗證裝置",
"txt_log_meta_web_session": "網頁工作階段",
"txt_log_reason_bad_api_key": "API 金鑰錯誤",
"txt_log_reason_bad_password": "密碼錯誤",
"txt_log_reason_device_missing": "裝置不存在",
"txt_log_reason_device_session_mismatch": "裝置工作階段不相符",
"txt_log_reason_token_not_found_or_expired": "權杖不存在或已過期",
"txt_log_reason_user_inactive": "使用者未啟用",
"txt_log_reason_user_missing": "使用者不存在",
"txt_log_target_type_attachment": "附件",
"txt_log_target_type_audit_log": "日誌",
"txt_log_target_type_backup": "備份",
"txt_log_target_type_cipher": "密碼項",
"txt_log_target_type_device": "裝置",
"txt_log_target_type_folder": "資料夾",
"txt_log_target_type_invite": "邀請",
"txt_log_target_type_refresh_token": "刷新權杖",
"txt_log_target_type_send": "Send",
"txt_log_target_type_user": "使用者",
"txt_log_trigger_manual": "手動",
"txt_log_trigger_remote": "遠端",
"txt_log_trigger_scheduled": "排程工作",
"txt_log_max_1000": "最多 1,000 筆",
"txt_log_max_5000": "最多 5,000 筆",
"txt_log_max_10000": "最多 10,000 筆",
"txt_log_max_50000": "最多 50,000 筆",
"txt_log_max_entries": "容量上限",
"txt_log_max_unlimited": "不限制筆數",
"txt_log_retention_7d": "保留 7 天",
"txt_log_retention_30d": "保留 30 天",
"txt_log_retention_90d": "保留 90 天",
"txt_log_retention_180d": "保留 180 天",
"txt_log_retention_365d": "保留 365 天",
"txt_log_retention_days": "保留時間",
"txt_log_retention_forever": "永久保留",
"txt_log_retention_hint": "按時間和最大筆數自動收縮,減少 D1 儲存占用。",
"txt_log_retention_mode": "保留方式",
"txt_log_retention_mode_days": "按時間",
"txt_log_retention_mode_entries": "按筆數",
"txt_log_retention_settings": "日誌保留",
"txt_log_settings": "設定",
"txt_log_settings_save_failed": "儲存日誌設定失敗",
"txt_log_settings_saved": "日誌設定已儲存",
"txt_log_search_placeholder": "搜尋動作、操作者、目標、請求路徑或元資料",
"txt_log_total": " 條總數",
"txt_log_visible": " 條顯示",
"txt_metadata": "元資料",
"txt_no_logs_found": "沒有找到日誌",
"txt_no_metadata": "沒有元資料",
"txt_clear_all_logs": "清空日誌",
"txt_clear_logs_confirm": "確定清空全部日誌嗎?此操作無法復原。",
"txt_clear_logs_failed": "清空日誌失敗",
"txt_logs_cleared": "日誌已清空",
"txt_search": "搜尋",
"txt_target": "目標",
"txt_time": "時間",
"txt_time_range": "時間範圍",
"txt_remove_domain": "移除域名" "txt_remove_domain": "移除域名"
}; };
+58 -9
View File
@@ -30,14 +30,16 @@ function concatBytes(...parts: Uint8Array[]): Uint8Array {
return out; return out;
} }
function encodeSshString(value: Uint8Array): Uint8Array { function encodeUint32(value: number): Uint8Array {
const out = new Uint8Array(4 + value.length); const out = new Uint8Array(4);
const view = new DataView(out.buffer); new DataView(out.buffer).setUint32(0, value >>> 0, false);
view.setUint32(0, value.length, false);
out.set(value, 4);
return out; return out;
} }
function encodeSshString(value: Uint8Array): Uint8Array {
return concatBytes(encodeUint32(value.length), value);
}
function extractSshBlobFromPublicKey(publicKey: string): Uint8Array | null { function extractSshBlobFromPublicKey(publicKey: string): Uint8Array | null {
const text = String(publicKey || '').trim(); const text = String(publicKey || '').trim();
if (!text) return null; if (!text) return null;
@@ -59,11 +61,11 @@ export async function computeSshFingerprint(publicKey: string): Promise<string>
return `SHA256:${bytesToBase64(digest).replace(/=+$/g, '')}`; return `SHA256:${bytesToBase64(digest).replace(/=+$/g, '')}`;
} }
function toPem(tag: string, bytes: Uint8Array): string { function toOpenSshPrivateKeyPem(bytes: Uint8Array): string {
const b64 = bytesToBase64(bytes); const b64 = bytesToBase64(bytes);
const chunks: string[] = []; const chunks: string[] = [];
for (let i = 0; i < b64.length; i += 64) chunks.push(b64.slice(i, i + 64)); for (let i = 0; i < b64.length; i += 70) chunks.push(b64.slice(i, i + 70));
return `-----BEGIN ${tag}-----\n${chunks.join('\n')}\n-----END ${tag}-----`; return `-----BEGIN OPENSSH PRIVATE KEY-----\n${chunks.join('\n')}\n-----END OPENSSH PRIVATE KEY-----\n`;
} }
function extractEd25519RawPublicKey(spki: Uint8Array): Uint8Array | null { function extractEd25519RawPublicKey(spki: Uint8Array): Uint8Array | null {
@@ -74,17 +76,64 @@ function extractEd25519RawPublicKey(spki: Uint8Array): Uint8Array | null {
return null; return null;
} }
function extractEd25519SeedFromPkcs8(pkcs8: Uint8Array): Uint8Array | null {
const prefix = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]);
const hasPrefix = pkcs8.length >= prefix.length + 32 && prefix.every((value, idx) => pkcs8[idx] === value);
if (hasPrefix) return pkcs8.slice(prefix.length, prefix.length + 32);
for (let i = 0; i <= pkcs8.length - 34; i += 1) {
if (pkcs8[i] === 0x04 && pkcs8[i + 1] === 0x20) {
return pkcs8.slice(i + 2, i + 34);
}
}
return null;
}
function buildOpenSshEd25519PrivateKey(seed: Uint8Array, rawPublic: Uint8Array, comment = ''): string {
const encoder = new TextEncoder();
const keyType = encoder.encode('ssh-ed25519');
const sshBlob = concatBytes(encodeSshString(keyType), encodeSshString(rawPublic));
const privateKey = concatBytes(seed, rawPublic);
const check = crypto.getRandomValues(new Uint8Array(4));
let privateBlock = concatBytes(
check,
check,
encodeSshString(keyType),
encodeSshString(rawPublic),
encodeSshString(privateKey),
encodeSshString(encoder.encode(comment))
);
const paddingLength = (8 - (privateBlock.length % 8)) % 8 || 8;
const padding = new Uint8Array(paddingLength);
for (let i = 0; i < paddingLength; i += 1) padding[i] = i + 1;
privateBlock = concatBytes(privateBlock, padding);
const authMagic = encoder.encode('openssh-key-v1\0');
const payload = concatBytes(
authMagic,
encodeSshString(encoder.encode('none')),
encodeSshString(encoder.encode('none')),
encodeSshString(new Uint8Array(0)),
encodeUint32(1),
encodeSshString(sshBlob),
encodeSshString(privateBlock)
);
return toOpenSshPrivateKeyPem(payload);
}
export async function generateDefaultSshKeyMaterial(): Promise<{ privateKey: string; publicKey: string; fingerprint: string }> { export async function generateDefaultSshKeyMaterial(): Promise<{ privateKey: string; publicKey: string; fingerprint: string }> {
const keyPair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); const keyPair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)); const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey)); const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
const rawPublic = extractEd25519RawPublicKey(spki); const rawPublic = extractEd25519RawPublicKey(spki);
if (!rawPublic) throw new Error('Cannot export Ed25519 public key'); if (!rawPublic) throw new Error('Cannot export Ed25519 public key');
const seed = extractEd25519SeedFromPkcs8(pkcs8);
if (!seed) throw new Error('Cannot export Ed25519 private key');
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const sshBlob = concatBytes(encodeSshString(encoder.encode('ssh-ed25519')), encodeSshString(rawPublic)); const sshBlob = concatBytes(encodeSshString(encoder.encode('ssh-ed25519')), encodeSshString(rawPublic));
const publicKey = `ssh-ed25519 ${bytesToBase64(sshBlob)}`; const publicKey = `ssh-ed25519 ${bytesToBase64(sshBlob)}`;
const privateKey = toPem('PRIVATE KEY', pkcs8); const privateKey = buildOpenSshEd25519PrivateKey(seed, rawPublic);
const fingerprint = await computeSshFingerprint(publicKey); const fingerprint = await computeSshFingerprint(publicKey);
return { privateKey, publicKey, fingerprint }; return { privateKey, publicKey, fingerprint };
} }
+36
View File
@@ -281,6 +281,11 @@ export interface VaultDraft {
export interface ListResponse<T> { export interface ListResponse<T> {
object: 'list'; object: 'list';
data: T[]; data: T[];
total?: number;
limit?: number;
offset?: number;
hasMore?: boolean;
continuationToken?: string | null;
} }
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
@@ -344,6 +349,37 @@ export interface AdminInvite {
expiresAt?: string; expiresAt?: string;
} }
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
export interface AuditLogEntry {
id: string;
actorUserId: string | null;
actorEmail?: string | null;
action: string;
category: AuditLogCategory;
level: AuditLogLevel;
targetType: string | null;
targetId: string | null;
targetUserEmail?: string | null;
metadata: string | null;
createdAt: string;
object?: 'auditLog';
}
export interface AuditLogSettings {
retentionDays: number | null;
maxEntries: number | null;
}
export interface AuditLogListResult {
logs: AuditLogEntry[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
export interface AuthorizedDevice { export interface AuthorizedDevice {
id: string; id: string;
name: string; name: string;
+71 -41
View File
@@ -1,5 +1,5 @@
import { base64ToBytes, decryptBw, decryptStr } from './crypto'; import { base64ToBytes, decryptBw, decryptStr } from './crypto';
import { deriveSendKeyParts } from './app-support'; import { deriveSendKeyParts, looksLikeCipherString } from './app-support';
import type { Cipher, Folder, Send } from './types'; import type { Cipher, Folder, Send } from './types';
export interface DecryptVaultCoreArgs { export interface DecryptVaultCoreArgs {
@@ -38,10 +38,34 @@ async function decryptField(
try { try {
return await decryptStr(value, enc, mac); return await decryptStr(value, enc, mac);
} catch { } catch {
return value; return looksLikeCipherString(value) ? '' : value;
} }
} }
async function decryptCipherField(
value: string | null | undefined,
itemEnc: Uint8Array,
itemMac: Uint8Array,
userEnc: Uint8Array,
userMac: Uint8Array,
canFallbackToUserKey: boolean
): Promise<string> {
if (!value || typeof value !== 'string') return '';
try {
return await decryptStr(value, itemEnc, itemMac);
} catch {
// Try the legacy user-key path for mixed key/field ciphers.
}
if (canFallbackToUserKey) {
try {
return await decryptStr(value, userEnc, userMac);
} catch {
// Preserve the old raw fallback for fields that are genuinely unreadable.
}
}
return looksLikeCipherString(value) ? '' : value;
}
async function decryptFieldWithSource( async function decryptFieldWithSource(
value: string | null | undefined, value: string | null | undefined,
itemEnc: Uint8Array, itemEnc: Uint8Array,
@@ -64,7 +88,7 @@ async function decryptFieldWithSource(
// Keep plain fallback. // Keep plain fallback.
} }
} }
return { text: raw, source: 'plain' }; return { text: looksLikeCipherString(raw) ? '' : raw, source: 'plain' };
} }
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> { export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
@@ -82,32 +106,38 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
args.ciphers.map(async (cipher) => { args.ciphers.map(async (cipher) => {
let itemEnc = userEnc; let itemEnc = userEnc;
let itemMac = userMac; let itemMac = userMac;
let usesItemKey = false;
if (cipher.key) { if (cipher.key) {
try { try {
const itemKey = await decryptBw(cipher.key, userEnc, userMac); const itemKey = await decryptBw(cipher.key, userEnc, userMac);
itemEnc = itemKey.slice(0, 32); if (itemKey.length >= 64) {
itemMac = itemKey.slice(32, 64); itemEnc = itemKey.slice(0, 32);
itemMac = itemKey.slice(32, 64);
usesItemKey = true;
}
} catch { } catch {
// Keep user key fallback. // Keep user key fallback.
} }
} }
const itemUsesUserKey = sameBytes(itemEnc, userEnc) && sameBytes(itemMac, userMac); const itemUsesUserKey = sameBytes(itemEnc, userEnc) && sameBytes(itemMac, userMac);
const canFallbackToUserKey = usesItemKey;
const nextCipher: Cipher = { const nextCipher: Cipher = {
...cipher, ...cipher,
decName: await decryptField(cipher.name || '', itemEnc, itemMac), decName: await decryptCipherField(cipher.name || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac), decNotes: await decryptCipherField(cipher.notes || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}; };
if (cipher.login) { if (cipher.login) {
nextCipher.login = { nextCipher.login = {
...cipher.login, ...cipher.login,
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac), decUsername: await decryptCipherField(cipher.login.username || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac), decPassword: await decryptCipherField(cipher.login.password || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac), decTotp: await decryptCipherField(cipher.login.totp || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
uris: await Promise.all( uris: await Promise.all(
(cipher.login.uris || []).map(async (uri) => ({ (cipher.login.uris || []).map(async (uri) => ({
...uri, ...uri,
decUri: await decryptField(uri.uri || '', itemEnc, itemMac), decUri: await decryptCipherField(uri.uri || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
})) }))
), ),
}; };
@@ -117,7 +147,7 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
nextCipher.passwordHistory = await Promise.all( nextCipher.passwordHistory = await Promise.all(
cipher.passwordHistory.map(async (entry) => ({ cipher.passwordHistory.map(async (entry) => ({
...entry, ...entry,
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac), decPassword: await decryptCipherField(entry?.password || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
})) }))
); );
} }
@@ -125,36 +155,36 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
if (cipher.card) { if (cipher.card) {
nextCipher.card = { nextCipher.card = {
...cipher.card, ...cipher.card,
decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac), decCardholderName: await decryptCipherField(cipher.card.cardholderName || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac), decNumber: await decryptCipherField(cipher.card.number || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac), decBrand: await decryptCipherField(cipher.card.brand || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac), decExpMonth: await decryptCipherField(cipher.card.expMonth || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac), decExpYear: await decryptCipherField(cipher.card.expYear || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac), decCode: await decryptCipherField(cipher.card.code || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}; };
} }
if (cipher.identity) { if (cipher.identity) {
nextCipher.identity = { nextCipher.identity = {
...cipher.identity, ...cipher.identity,
decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac), decTitle: await decryptCipherField(cipher.identity.title || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac), decFirstName: await decryptCipherField(cipher.identity.firstName || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac), decMiddleName: await decryptCipherField(cipher.identity.middleName || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac), decLastName: await decryptCipherField(cipher.identity.lastName || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac), decUsername: await decryptCipherField(cipher.identity.username || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac), decCompany: await decryptCipherField(cipher.identity.company || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac), decSsn: await decryptCipherField(cipher.identity.ssn || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac), decPassportNumber: await decryptCipherField(cipher.identity.passportNumber || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac), decLicenseNumber: await decryptCipherField(cipher.identity.licenseNumber || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac), decEmail: await decryptCipherField(cipher.identity.email || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac), decPhone: await decryptCipherField(cipher.identity.phone || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac), decAddress1: await decryptCipherField(cipher.identity.address1 || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac), decAddress2: await decryptCipherField(cipher.identity.address2 || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac), decAddress3: await decryptCipherField(cipher.identity.address3 || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac), decCity: await decryptCipherField(cipher.identity.city || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac), decState: await decryptCipherField(cipher.identity.state || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac), decPostalCode: await decryptCipherField(cipher.identity.postalCode || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac), decCountry: await decryptCipherField(cipher.identity.country || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}; };
} }
@@ -162,11 +192,11 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || ''; const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
nextCipher.sshKey = { nextCipher.sshKey = {
...cipher.sshKey, ...cipher.sshKey,
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac), decPrivateKey: await decryptCipherField(cipher.sshKey.privateKey || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac), decPublicKey: await decryptCipherField(cipher.sshKey.publicKey || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
keyFingerprint: encryptedFingerprint || null, keyFingerprint: encryptedFingerprint || null,
fingerprint: encryptedFingerprint || null, fingerprint: encryptedFingerprint || null,
decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac), decFingerprint: await decryptCipherField(encryptedFingerprint, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
}; };
} }
@@ -174,8 +204,8 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
nextCipher.fields = await Promise.all( nextCipher.fields = await Promise.all(
cipher.fields.map(async (field) => ({ cipher.fields.map(async (field) => ({
...field, ...field,
decName: await decryptField(field.name || '', itemEnc, itemMac), decName: await decryptCipherField(field.name || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
decValue: await decryptField(field.value || '', itemEnc, itemMac), decValue: await decryptCipherField(field.value || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
})) }))
); );
} }
+143 -9
View File
@@ -18,8 +18,6 @@
.dialog-card, .dialog-card,
.card, .card,
.list-panel, .list-panel,
.sidebar-block,
.settings-subcard,
.backup-operations-sidebar, .backup-operations-sidebar,
.backup-destination-sidebar, .backup-destination-sidebar,
.backup-detail-panel, .backup-detail-panel,
@@ -110,7 +108,6 @@
:root[data-theme='dark'] .dialog-card, :root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .card, :root[data-theme='dark'] .card,
:root[data-theme='dark'] .list-panel, :root[data-theme='dark'] .list-panel,
:root[data-theme='dark'] .sidebar-block,
:root[data-theme='dark'] .settings-subcard, :root[data-theme='dark'] .settings-subcard,
:root[data-theme='dark'] .backup-operations-sidebar, :root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar, :root[data-theme='dark'] .backup-destination-sidebar,
@@ -423,7 +420,6 @@ h4 {
.dialog-card, .dialog-card,
.card, .card,
.list-panel, .list-panel,
.sidebar-block,
.settings-subcard, .settings-subcard,
.backup-operations-sidebar, .backup-operations-sidebar,
.backup-destination-sidebar, .backup-destination-sidebar,
@@ -659,6 +655,40 @@ h4 {
padding: 14px; padding: 14px;
} }
.route-stage,
.sidebar,
.list-panel,
.detail-col,
.log-list,
.card.log-detail-panel,
.domain-rules-table,
.route-stage-fixed {
scrollbar-gutter: stable;
}
.route-stage,
.sidebar {
margin-right: -4px;
padding-right: 4px;
}
.list-panel,
.detail-col {
margin-right: -4px;
padding-right: 12px;
}
.log-list,
.domain-rules-table {
margin-right: -4px;
padding-right: calc(0.125rem + 4px);
}
.card.log-detail-panel {
margin-right: -4px;
padding-right: 18px;
}
.card h4, .card h4,
.settings-module h3, .settings-module h3,
.section-head h3, .section-head h3,
@@ -687,11 +717,16 @@ h4 {
color: var(--muted); color: var(--muted);
} }
.settings-modules-grid,
.import-export-panels, .import-export-panels,
.backup-grid, .backup-grid,
.domain-rules-grid { .domain-rules-grid {
gap: 10px; gap: 2px 10px;
}
.settings-modules-grid {
--settings-grid-gap: 10px;
gap: 2px var(--settings-grid-gap);
grid-template-columns: repeat(2, minmax(0, calc((100% - var(--settings-grid-gap)) / 2)));
} }
.settings-module h3 { .settings-module h3 {
@@ -793,7 +828,6 @@ h4 {
:root[data-theme='dark'] .dialog-card, :root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .card, :root[data-theme='dark'] .card,
:root[data-theme='dark'] .list-panel, :root[data-theme='dark'] .list-panel,
:root[data-theme='dark'] .sidebar-block,
:root[data-theme='dark'] .settings-subcard, :root[data-theme='dark'] .settings-subcard,
:root[data-theme='dark'] .backup-operations-sidebar, :root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar, :root[data-theme='dark'] .backup-destination-sidebar,
@@ -889,6 +923,36 @@ h4 {
gap: 8px; gap: 8px;
} }
.route-stage,
.sidebar,
.list-panel,
.detail-col,
.log-list,
.card.log-detail-panel,
.domain-rules-table,
.route-stage-fixed {
margin-right: 0;
scrollbar-gutter: auto;
}
.route-stage,
.sidebar {
padding-right: 0;
}
.log-list,
.domain-rules-table {
padding-right: 0.125rem;
}
.card.log-detail-panel {
padding-right: 14px;
}
.settings-modules-grid {
grid-template-columns: minmax(0, 1fr);
}
.mobile-detail-sheet { .mobile-detail-sheet {
background: var(--panel-soft); background: var(--panel-soft);
} }
@@ -1089,7 +1153,6 @@ input[type='file'].input::file-selector-button {
padding: 12px; padding: 12px;
border: 1px solid var(--line-soft); border: 1px solid var(--line-soft);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--panel-soft);
} }
.invite-create-group { .invite-create-group {
@@ -1122,7 +1185,6 @@ input[type='file'].input::file-selector-button {
.invite-table th { .invite-table th {
height: 42px; height: 42px;
padding: 9px 10px; padding: 9px 10px;
background: var(--panel-soft);
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
} }
@@ -1236,3 +1298,75 @@ input[type='file'].input::file-selector-button {
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
} }
} }
/* Keep management routes mobile-first after the final polish layer overrides. */
@media (max-width: 1180px) {
.content,
.route-stage,
.stack,
.backup-grid {
min-width: 0;
max-width: 100%;
}
.route-stage {
overflow-x: hidden;
}
.backup-grid {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 0;
}
.backup-operations-sidebar,
.backup-destination-sidebar,
.backup-detail-panel {
width: 100%;
}
}
@media (max-width: 760px) {
.table,
.table tbody,
.table tr,
.table td {
display: block;
width: 100%;
}
.table thead {
display: none;
}
.table {
border-spacing: 0;
}
.table tr {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.table td {
padding: 10px 0;
border-bottom: 1px solid var(--line-soft);
overflow-wrap: anywhere;
}
.table td:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.table td::before {
display: block;
content: attr(data-label);
margin-bottom: 4px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
}
+13 -4
View File
@@ -36,9 +36,14 @@ body.dialog-open {
} }
/* --- custom scrollbar --- */ /* --- custom scrollbar --- */
* {
scrollbar-color: color-mix(in srgb, var(--muted) 34%, transparent) transparent;
scrollbar-width: thin;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 10px;
height: 6px; height: 10px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -46,12 +51,16 @@ body.dialog-open {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 30%, transparent); min-height: 44px;
border: 3px solid transparent;
border-radius: 999px; border-radius: 999px;
background: color-mix(in srgb, var(--muted) 30%, transparent);
background-clip: content-box;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--muted) 50%, transparent); background: color-mix(in srgb, var(--primary) 38%, var(--muted) 22%);
background-clip: content-box;
} }
::-webkit-scrollbar-corner { ::-webkit-scrollbar-corner {
+34 -2
View File
@@ -151,11 +151,13 @@
/* ── dark mode scrollbar ── */ /* ── dark mode scrollbar ── */
:root[data-theme='dark'] ::-webkit-scrollbar-thumb { :root[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 24%, transparent); background: color-mix(in srgb, var(--muted) 30%, transparent);
background-clip: content-box;
} }
:root[data-theme='dark'] ::-webkit-scrollbar-thumb:hover { :root[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--muted) 44%, transparent); background: color-mix(in srgb, var(--primary) 40%, var(--muted) 22%);
background-clip: content-box;
} }
/* ── dark mode backdrop-filter ── */ /* ── dark mode backdrop-filter ── */
@@ -336,3 +338,33 @@
background: color-mix(in srgb, var(--primary) 18%, var(--panel)); background: color-mix(in srgb, var(--primary) 18%, var(--panel));
color: var(--primary-strong); color: var(--primary-strong);
} }
:root[data-theme='dark'] .log-detail-head h3,
:root[data-theme='dark'] .log-row-main strong,
:root[data-theme='dark'] .log-detail-meta strong,
:root[data-theme='dark'] .log-detail-json dd,
:root[data-theme='dark'] .log-detail-json h4,
:root[data-theme='dark'] .log-pagination-count {
color: var(--text);
}
:root[data-theme='dark'] .log-row-main small,
:root[data-theme='dark'] .log-detail-meta span,
:root[data-theme='dark'] .log-detail-json dt {
color: var(--muted);
}
:root[data-theme='dark'] .log-row,
:root[data-theme='dark'] .log-detail-meta > div,
:root[data-theme='dark'] .log-detail-json dl > div,
:root[data-theme='dark'] .log-pagination-count {
background: var(--panel-muted);
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .log-row:hover,
:root[data-theme='dark'] .log-row.active {
background: color-mix(in srgb, var(--primary) 12%, var(--panel));
border-color: color-mix(in srgb, var(--primary) 34%, var(--line));
}
+6 -4
View File
@@ -11,14 +11,15 @@
} }
.input { .input {
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-none transition; @apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base leading-normal text-ink outline-none transition;
background: var(--panel); background: var(--panel);
border-color: rgba(74, 103, 150, 0.34); border-color: rgba(74, 103, 150, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
} }
select.input { select.input {
@apply pr-[42px]; @apply py-0 pr-[42px];
line-height: 1.5;
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
@@ -103,13 +104,14 @@ input[type='file'].input::file-selector-button:hover {
} }
.eye-btn { .eye-btn {
@apply absolute bottom-2.5 right-2.5 grid h-8 w-8 cursor-pointer place-items-center border-0 bg-transparent text-slate-700 transition; @apply absolute right-2.5 top-1/2 grid h-8 w-8 cursor-pointer place-items-center border-0 bg-transparent text-slate-700 transition;
transform: translateY(-50%);
} }
.password-toggle:hover, .password-toggle:hover,
.eye-btn:hover { .eye-btn:hover {
color: var(--primary); color: var(--primary);
transform: translateY(-1px) scale(1.04); transform: translateY(-50%) scale(1.04);
} }
.btn { .btn {
+591 -3
View File
@@ -425,7 +425,7 @@
} }
.totp-grid { .totp-grid {
@apply mb-3.5 grid gap-3.5; @apply grid gap-3.5;
grid-template-columns: 220px 1fr; grid-template-columns: 220px 1fr;
} }
@@ -497,12 +497,542 @@
} }
.settings-modules-grid { .settings-modules-grid {
@apply grid gap-3; --settings-grid-gap: 12px;
@apply grid;
gap: var(--settings-grid-gap);
grid-template-columns: repeat(2, minmax(0, calc((100% - var(--settings-grid-gap)) / 2)));
}
.log-center-page {
@apply grid h-full min-h-0 gap-3;
height: 100%;
max-height: 100%;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.card.log-center-toolbar {
@apply relative;
margin-bottom: 0;
}
.log-mobile-subhead {
display: none;
}
.log-detail-head h3 {
@apply m-0 text-base font-extrabold;
color: #0f172a;
}
.log-filter-form {
@apply grid items-end gap-3;
grid-template-columns: minmax(260px, 1.5fr) repeat(3, minmax(150px, 0.66fr)) auto;
}
.log-filter-form .field {
@apply mb-0;
}
.log-filter-form .input,
.log-filter-form .btn {
min-height: 42px;
}
.log-search-field {
@apply min-w-0;
}
.input-leading-icon {
@apply pointer-events-none absolute left-3 top-1/2 -translate-y-1/2;
color: #64748b;
}
.log-search-input {
padding-left: 2.25rem;
}
.log-filter-actions {
@apply flex-nowrap items-end;
align-self: end;
}
.log-filter-actions .btn {
white-space: nowrap;
}
.log-settings-popover {
@apply absolute right-3 z-30 grid gap-3 rounded-xl border p-3;
top: calc(100% + 8px);
width: min(390px, calc(100vw - 32px));
border-color: var(--line);
background: #ffffff;
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.16);
}
.log-settings-popover-head {
@apply mb-0;
}
.log-settings-popover-head h3 {
@apply m-0 text-base font-extrabold;
color: #0f172a;
}
.log-settings-mode {
@apply grid rounded-lg p-1;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
background: #f1f5f9;
}
.log-mode-option {
@apply h-9 cursor-pointer rounded-md border-0 px-2 text-sm font-extrabold;
background: transparent;
color: #475569;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.log-mode-option.active {
background: #ffffff;
color: #1d4ed8;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.12);
}
.log-mode-option:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.log-settings-retention-block {
@apply grid gap-1.5;
}
.log-settings-label {
@apply block text-[13px] font-bold;
color: var(--muted-strong);
}
.log-settings-retention-row {
@apply grid items-center gap-2.5;
grid-template-columns: minmax(0, 1fr) 82px;
}
.log-settings-retention-row .input {
width: 100%;
min-width: 0;
height: 42px;
min-height: 42px;
}
.log-settings-save-btn.btn {
width: 82px;
height: 42px;
min-height: 42px;
align-self: center;
justify-content: center;
padding-inline: 10px;
white-space: nowrap;
transform: none;
}
.log-settings-save-btn.btn:hover:not(:disabled),
.log-settings-save-btn.btn:active:not(:disabled) {
transform: none;
}
.log-settings-danger {
@apply grid gap-2 border-t pt-3;
border-color: var(--line);
}
.log-settings-danger p {
@apply m-0 text-sm font-semibold leading-5;
color: #7f1d1d;
}
.ghost-danger {
@apply w-full justify-center;
}
.log-clear-confirm-actions {
@apply grid grid-cols-2;
}
.log-center-grid {
@apply grid min-h-0 items-stretch gap-3;
height: 100%;
max-height: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: hidden;
}
.card.log-list-panel,
.card.log-detail-panel {
height: 100%;
max-height: 100%;
margin-bottom: 0;
min-height: 0;
min-width: 0;
}
.card.log-list-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.card.log-detail-panel {
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.log-list {
@apply grid content-start gap-2 overflow-auto pr-0.5;
min-height: 0;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.log-list-panel > .section-head,
.log-pagination {
flex-shrink: 0;
}
.log-row {
@apply grid w-full cursor-pointer items-center gap-3 rounded-xl p-3 text-left;
grid-template-columns: auto minmax(0, 1fr) auto;
border: 1px solid var(--line);
background: #ffffff;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.log-row:hover,
.log-row.active {
border-color: #93c5fd;
background: #f8fbff;
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.08);
}
.log-row-icon {
@apply flex h-9 w-9 items-center justify-center rounded-xl;
}
.log-category-auth {
background: #eff6ff;
color: #1d4ed8;
}
.log-category-security {
background: #fff1f2;
color: #be123c;
}
.log-category-device {
background: #ecfdf5;
color: #047857;
}
.log-category-data {
background: #f5f3ff;
color: #6d28d9;
}
.log-category-system {
background: #f8fafc;
color: #475467;
}
.log-row-main {
@apply grid min-w-0 gap-1;
}
.log-row-main strong {
@apply truncate text-sm;
color: #0f172a;
}
.log-row-main small {
@apply text-xs;
color: #64748b;
}
.log-level-pill {
@apply inline-flex whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-extrabold;
}
.log-level-info {
background: #eef4ff;
color: #1d4ed8;
}
.log-level-warn {
background: #fff7ed;
color: #c2410c;
}
.log-level-error {
background: #fef2f2;
color: #b91c1c;
}
.log-level-security {
background: #fff1f2;
color: #be123c;
}
.log-pagination {
@apply mt-3 items-center justify-between;
}
.log-pagination-count {
@apply inline-flex min-w-24 items-center justify-center rounded-full px-3 py-1.5 text-sm font-extrabold;
border: 1px solid var(--line);
background: #f8fafc;
color: #0f172a;
}
.log-detail-meta {
@apply grid gap-2;
}
.log-detail-meta > div,
.log-detail-json dl > div {
@apply grid gap-1 rounded-xl px-3 py-2.5;
border: 1px solid var(--line);
background: #f8fafc;
}
.log-detail-meta span,
.log-detail-json dt {
@apply text-xs font-bold uppercase;
color: #64748b;
}
.log-detail-meta strong,
.log-detail-json dd {
@apply m-0 min-w-0 text-sm font-semibold;
color: #0f172a;
overflow-wrap: anywhere;
}
.log-detail-json {
@apply mt-3 grid gap-2;
}
.log-detail-json h4 {
@apply m-0 text-sm font-extrabold;
color: #0f172a;
}
.log-detail-json dl {
@apply m-0 grid gap-2;
}
@media (max-width: 1120px) {
.log-filter-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.log-filter-actions {
@apply col-span-2;
}
.log-center-grid {
grid-template-columns: 1fr;
grid-template-rows: repeat(2, minmax(220px, 1fr));
}
}
@media (max-width: 760px) {
.route-stage-log-fixed {
overflow: hidden;
}
.log-center-page {
gap: 8px;
grid-template-rows: auto auto minmax(0, 1fr);
}
.log-center-page.log-mobile-detail-open {
grid-template-rows: auto minmax(0, 1fr);
}
.log-mobile-subhead {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
min-height: 38px;
flex-shrink: 0;
padding-top: 2px;
}
.log-mobile-subhead .mobile-settings-back {
margin-right: auto;
}
.log-mobile-settings-trigger {
width: 42px;
height: 38px;
justify-content: center;
padding: 0;
}
.log-mobile-settings-trigger .btn-icon {
margin: 0;
}
.log-mobile-detail-open .log-mobile-settings-trigger {
display: none;
}
.card.log-center-toolbar {
padding: 10px 12px;
}
.log-mobile-detail-open .card.log-center-toolbar {
display: none;
}
.log-filter-form {
gap: 6px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.log-filter-actions {
display: none;
}
.log-search-field > span {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.log-search-field {
grid-column: 1 / -1;
}
.log-filter-form .field {
margin-bottom: 0;
min-width: 0;
}
.log-filter-form > .field:not(.log-search-field) > span {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.log-filter-form .input {
min-height: 40px;
height: 40px;
width: 100%;
min-width: 0;
font-size: 13px;
padding-inline: 9px 26px;
}
.log-search-input {
font-size: 14px;
padding-left: 2.15rem;
padding-right: 10px;
}
.log-filter-form select.input {
text-overflow: ellipsis;
}
.log-center-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.card.log-list-panel {
grid-template-rows: minmax(0, 1fr);
padding: 8px;
}
.log-list-panel > .section-head,
.log-pagination {
display: none;
}
.card.log-detail-panel {
display: none;
}
.log-mobile-detail-open .card.log-list-panel {
display: none;
}
.log-mobile-detail-open .card.log-detail-panel {
display: block;
height: 100%;
max-height: 100%;
overflow: auto;
padding: 10px 12px 14px;
}
.log-settings-popover {
@apply static mt-3 w-full;
}
.log-settings-retention-row {
grid-template-columns: minmax(0, 1fr) 82px;
}
.log-row {
min-height: 66px;
gap: 12px;
grid-template-columns: 38px minmax(0, 1fr) auto;
padding: 10px 12px;
}
.log-row .log-level-pill {
grid-column: auto;
justify-self: end;
}
.log-row-icon {
width: 38px;
height: 38px;
border-radius: 12px;
}
.log-row-main {
justify-items: center;
text-align: center;
}
.log-row-main strong {
max-width: 100%;
font-size: 14px;
}
.log-row-main small {
font-size: 12px;
}
} }
.settings-module { .settings-module {
@apply min-w-0; @apply min-w-0;
width: 100%;
} }
.sensitive-actions-module { .sensitive-actions-module {
@@ -517,6 +1047,21 @@
color: var(--text); color: var(--text);
} }
.settings-module-head {
@apply mb-[5px] flex items-center justify-between gap-3;
}
.settings-module-head h3 {
@apply m-0;
}
.totp-status-pill {
@apply inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm font-extrabold;
border: 1px solid color-mix(in srgb, var(--success) 26%, var(--line));
background: color-mix(in srgb, var(--success) 9%, var(--panel));
color: var(--success);
}
.settings-module-placeholder { .settings-module-placeholder {
@apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold; @apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold;
color: var(--muted); color: var(--muted);
@@ -536,7 +1081,7 @@
} }
.sensitive-actions-grid { .sensitive-actions-grid {
@apply grid gap-3; @apply grid gap-[3px];
} }
.sensitive-action { .sensitive-action {
@@ -868,6 +1413,49 @@
color: #667085; color: #667085;
} }
.authorized-devices-table {
table-layout: fixed;
}
.authorized-devices-col-device {
width: 28%;
}
.authorized-devices-col-type {
width: 7%;
}
.authorized-devices-col-status {
width: 6%;
}
.authorized-devices-col-date {
width: 11%;
}
.authorized-devices-col-trust {
width: 11%;
}
.authorized-devices-col-actions {
width: 26%;
}
.authorized-devices-table td:first-child {
overflow-wrap: anywhere;
}
.authorized-devices-actions {
flex-wrap: nowrap;
gap: 6px;
}
.authorized-devices-actions .btn.small {
flex: 0 0 auto;
padding-inline: 8px;
white-space: nowrap;
}
.input.small { .input.small {
@apply w-[120px]; @apply w-[120px];
} }
+18
View File
@@ -855,6 +855,22 @@
line-height: 1.25; line-height: 1.25;
} }
.settings-module-head {
margin-bottom: 8px;
align-items: center;
gap: 8px;
}
.settings-module-head h3 {
margin: 0;
}
.totp-status-pill {
min-height: 30px;
padding: 0 10px;
font-size: 13px;
}
.settings-module .field, .settings-module .field,
.auth-card .field { .auth-card .field {
margin-bottom: 8px; margin-bottom: 8px;
@@ -882,6 +898,8 @@
} }
.settings-module select.input { .settings-module select.input {
padding-top: 0;
padding-bottom: 0;
padding-right: 30px; padding-right: 30px;
background-position: background-position:
calc(100% - 15px) calc(50% - 3px), calc(100% - 15px) calc(50% - 3px),
+6
View File
@@ -316,6 +316,12 @@
overflow: hidden; overflow: hidden;
} }
.route-stage-log-fixed {
display: grid;
grid-template-rows: minmax(0, 1fr);
overflow: hidden;
}
.mobile-sidebar-mask { .mobile-sidebar-mask {
@apply pointer-events-none invisible fixed inset-0 opacity-0; @apply pointer-events-none invisible fixed inset-0 opacity-0;
background: rgba(15, 23, 42, 0.36); background: rgba(15, 23, 42, 0.36);
+3
View File
@@ -2,6 +2,9 @@ name = "nodewarden"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
[build]
command = "npm run build"
[assets] [assets]
binding = "ASSETS" binding = "ASSETS"
directory = "./dist" directory = "./dist"
+3
View File
@@ -2,6 +2,9 @@ name = "nodewarden"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
[build]
command = "npm run build"
[assets] [assets]
binding = "ASSETS" binding = "ASSETS"
directory = "./dist" directory = "./dist"