mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
18 Commits
v1.5.2
...
04ebfc7021
| Author | SHA1 | Date | |
|---|---|---|---|
| 04ebfc7021 | |||
| c50247b8fe | |||
| 776408e9d0 | |||
| e641da517d | |||
| b7878ffe01 | |||
| bbad9d60a7 | |||
| ed58467766 | |||
| 2f911e66a6 | |||
| d06e050162 | |||
| d0dc31ce86 | |||
| f64abaa75d | |||
| 7312086f92 | |||
| 3e4c104e1d | |||
| 17ceec45b1 | |||
| 2685741386 | |||
| 83a1fc2376 | |||
| 06431c4145 | |||
| 700910099b |
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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的区别:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+92
-12
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+258
-2
@@ -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();
|
||||||
@@ -153,6 +175,16 @@ export function normalizeCipherLoginForCompatibility(login: any): any {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = [
|
||||||
@@ -241,6 +273,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
|
||||||
@@ -307,6 +529,7 @@ export function cipherToResponse(
|
|||||||
'licenseNumber',
|
'licenseNumber',
|
||||||
]);
|
]);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? 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,7 +551,7 @@ 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,
|
||||||
@@ -464,6 +687,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 +729,9 @@ 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 (!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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +786,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 +817,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 +846,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 +873,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 +1107,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 +1169,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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
+112
-10
@@ -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';
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -77,6 +77,82 @@ function handleMissingWebsiteIcon(): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPrivateIpv4(hostname: string): boolean {
|
||||||
|
const parts = hostname.split('.').map((part) => Number(part));
|
||||||
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
||||||
|
const [a, b] = parts;
|
||||||
|
return (
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && b === 168) ||
|
||||||
|
a === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedChangePasswordHost(hostname: string): boolean {
|
||||||
|
const normalized = hostname.toLowerCase().replace(/\.+$/, '');
|
||||||
|
return (
|
||||||
|
normalized === 'localhost' ||
|
||||||
|
normalized.endsWith('.localhost') ||
|
||||||
|
normalized.endsWith('.local') ||
|
||||||
|
normalized === '::1' ||
|
||||||
|
normalized.startsWith('[') ||
|
||||||
|
isPrivateIpv4(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePublicHttpUrl(rawUri: string | null): URL | null {
|
||||||
|
if (!rawUri) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUri);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
if (isBlockedChangePasswordHost(url.hostname)) return null;
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePasswordUri(request: Request): Promise<Response> {
|
||||||
|
const sourceUrl = parsePublicHttpUrl(new URL(request.url).searchParams.get('uri'));
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return jsonResponse({ uri: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wellKnownUrl = new URL('/.well-known/change-password', sourceUrl.origin);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(wellKnownUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: controller.signal,
|
||||||
|
cf: {
|
||||||
|
cacheEverything: true,
|
||||||
|
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||||
|
},
|
||||||
|
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||||
|
|
||||||
|
if (response.status < 300 || response.status >= 400) {
|
||||||
|
return jsonResponse({ uri: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = response.headers.get('Location');
|
||||||
|
if (!location) return jsonResponse({ uri: null });
|
||||||
|
|
||||||
|
const targetUrl = parsePublicHttpUrl(new URL(location, wellKnownUrl).toString());
|
||||||
|
if (!targetUrl) return jsonResponse({ uri: null });
|
||||||
|
|
||||||
|
return jsonResponse({ uri: targetUrl.toString() });
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ uri: null });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildIconServiceBase(origin: string): string {
|
function buildIconServiceBase(origin: string): string {
|
||||||
return `${origin}/icons`;
|
return `${origin}/icons`;
|
||||||
}
|
}
|
||||||
@@ -284,6 +360,12 @@ export async function handlePublicRoute(
|
|||||||
return jsonResponse(await buildWebBootstrapResponse(env));
|
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/icons/change-password-uri' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleChangePasswordUri(request);
|
||||||
|
}
|
||||||
|
|
||||||
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 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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+52
-9
@@ -23,6 +23,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 +48,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;
|
||||||
@@ -204,17 +239,18 @@ 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;
|
||||||
@@ -222,16 +258,23 @@ export class AuthService {
|
|||||||
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
||||||
if (!boundDevice) {
|
if (!boundDevice) {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
-1
@@ -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) {
|
||||||
@@ -614,6 +636,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(
|
||||||
|
|||||||
@@ -96,9 +96,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-3
@@ -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',
|
||||||
@@ -228,6 +230,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 +1040,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 +1081,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((count) => {
|
||||||
|
if (count > 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 +1413,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 +1440,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 +1491,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 +1534,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 +1544,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>
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
encryptFolderImportName,
|
encryptFolderImportName,
|
||||||
getAttachmentDownloadInfo,
|
getAttachmentDownloadInfo,
|
||||||
importCiphers,
|
importCiphers,
|
||||||
|
permanentDeleteCipher,
|
||||||
type CiphersImportPayload,
|
type CiphersImportPayload,
|
||||||
type ImportedCipherMapEntry,
|
type ImportedCipherMapEntry,
|
||||||
updateCipher,
|
updateCipher,
|
||||||
@@ -490,6 +491,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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
+147
-2
@@ -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 || [];
|
||||||
@@ -574,12 +576,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 +668,136 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildCipherPayload(
|
async function buildCipherPayload(
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
draft: VaultDraft,
|
draft: VaultDraft,
|
||||||
@@ -803,6 +941,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');
|
||||||
|
|||||||
@@ -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>>();
|
||||||
|
|||||||
@@ -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'));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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": "Удалить домен"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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": "移除域名"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+143
-9
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,13 +103,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 {
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user