9 Commits

Author SHA1 Message Date
shuaiplus b1b25fe678 feat: update version to 1.6.1 in package.json and app-version.ts 2026-06-12 16:47:45 +08:00
shuaiplus 7cf2ab7c88 feat: add formatDateTime function for improved date handling in SettingsPage 2026-06-12 16:41:58 +08:00
shuaiplus 1918735520 feat: refine two-factor authentication response handling to align with Bitwarden Identity 2026-06-12 16:31:43 +08:00
shuaiplus c652cc1533 feat: implement device login approval system
Add a complete device authentication approval flow that allows users to approve login requests from new devices on their already-authenticated devices.

Core features:
- Create authentication requests when logging in from new devices
- Display pending requests with device info, IP address, and fingerprint phrases
- Approve or deny requests from web interface with real-time notifications
- Support multiple auth request types (authenticate & unlock, unlock only)
- Automatic expiration and cleanup of stale requests

Backend changes:
- Add auth_requests table with proper indexes for efficient queries
- Implement full CRUD API for authentication requests
- Add notification hub integration for real-time updates
- Add device fingerprint phrase generation for security verification

Frontend changes:
- Add AuthRequestApprovalDialog component for approving/denying requests
- Add PendingAuthRequestsPanel component to display and manage pending requests
- Integrate panels into Security and Settings pages
- Add fingerprint wordlist for generating human-readable verification phrases
- Update i18n translations for all supported languages

Security considerations:
- Access code verification to prevent unauthorized access
- Device fingerprint validation for additional security layer
- IP address and country tracking for audit purposes
- Automatic expiration of old requests (15 minutes)
- Only most recent request per device can be approved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:12:11 +08:00
shuaiplus e9aef72df7 feat: add loading skeleton components and styles for improved UI experience 2026-06-11 21:00:16 +08:00
shuaiplus 9adb24d4bb feat: implement two-factor authentication endpoints and related functionality 2026-06-11 16:53:51 +08:00
shuaiplus 563570e3e0 feat: add compatibility validation for cipher fields during import and storage 2026-06-11 15:02:55 +08:00
shuaiplus 3035a77579 chore: update version to 1.6.0 in package.json and app-version.ts 2026-06-10 17:05:32 +08:00
shuaiplus 28333f0e9b feat: update README to enhance PWA and Passkey features descriptions 2026-06-10 16:51:07 +08:00
43 changed files with 10025 additions and 241 deletions
+6
View File
@@ -55,3 +55,9 @@ NodeWarden-compat/
.codex-upstream/bitwarden-browser/ .codex-upstream/bitwarden-browser/
.reasonix/ .reasonix/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
.mcp.json
opencode.jsonc
.cursor/
+25 -5
View File
@@ -34,16 +34,19 @@
| 能力 | Bitwarden | NodeWarden | 说明 | | 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---| |---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** | | 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 | | 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send | | Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | | 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份** | | **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份OneDrive/Google Drive等)** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** | | 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 | | TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 | | 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 | | 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP | | 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 | | SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
--- ---
@@ -110,10 +113,27 @@ npm run dev:kv
--- ---
## 云端备份说明 ## 主要特性
- 远程备份支持 **WebDAV****E3** ### PWA 渐进式 Web 应用
- 勾选“包含附件”后:
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json` - ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/` - 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传 - 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
+30 -10
View File
@@ -37,16 +37,19 @@
| Capability | Bitwarden | NodeWarden | Notes | | Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---| |---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** | | Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients | | Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV | | Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends | | Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** | | Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** | | **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** | | Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support | | TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration | | Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented | | Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP | | Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented | | SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
--- ---
@@ -99,17 +102,34 @@ npm run dev:kv
--- ---
## Cloud Backup Notes ## Key Features
- Remote backup supports **WebDAV** and **E3** ### PWA Progressive Web App
-**Install to desktop** - Runs like a native app
-**Offline usage** - Service Worker caching, view passwords offline
-**App shortcuts** - Quick launch vault, TOTP codes
-**Background decryption** - Web Worker handles decryption without blocking UI
### Passkey Passwordless Login
-**WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
-**PRF key unlock** - Passkey can unlock vault directly
-**Official client compatibility** - Chromium browser extension supports Passkey login
-**Multi-device sync** - Supports iCloud, Google Password Manager, etc.
### Cloud Backup Notes
- Remote backup supports **WebDAV** and **S3**
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
- When `Include attachments` is enabled: - When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json` - the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/` - actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time - later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore: - During remote restore:
- required attachment files are loaded from `attachments/` on demand - required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely - missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database - skipped attachments do not leave broken rows in the restored database
--- ---
+27
View File
@@ -188,6 +188,33 @@ CREATE TABLE IF NOT EXISTS devices (
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at); CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
CREATE TABLE IF NOT EXISTS auth_requests (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
organization_id TEXT,
type INTEGER NOT NULL,
request_device_identifier TEXT NOT NULL,
request_device_type INTEGER NOT NULL,
request_ip_address TEXT,
request_country_name TEXT,
response_device_identifier TEXT,
access_code TEXT NOT NULL,
public_key TEXT NOT NULL,
key TEXT,
master_password_hash TEXT,
approved INTEGER,
creation_date TEXT NOT NULL,
response_date TEXT,
authentication_date TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created
ON auth_requests(user_id, creation_date);
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending
ON auth_requests(user_id, approved, response_date, authentication_date, creation_date);
CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending
ON auth_requests(user_id, request_device_identifier, creation_date);
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens ( CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.5.2", "version": "1.6.1",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.5.2'; export const APP_VERSION = '1.6.1';
+129 -32
View File
@@ -5,13 +5,17 @@ const SIGNALR_RECORD_SEPARATOR = 0x1e;
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
type HubProtocol = 'json' | 'messagepack'; type HubProtocol = 'json' | 'messagepack';
type HubKind = 'user' | 'anonymous-auth-request';
interface WsAttachment { interface WsAttachment {
userId: string; kind: HubKind;
userId: string | null;
authRequestId: string | null;
handshakeComplete: boolean; handshakeComplete: boolean;
protocol: HubProtocol; protocol: HubProtocol;
deviceIdentifier: string | null; deviceIdentifier: string | null;
@@ -137,11 +141,12 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
function buildSignalRJsonInvocation( function buildSignalRJsonInvocation(
updateType: number, updateType: number,
payload: Record<string, unknown>, payload: Record<string, unknown>,
contextId: string | null contextId: string | null,
target: string = 'ReceiveMessage'
): string { ): string {
return JSON.stringify({ return JSON.stringify({
type: 1, type: 1,
target: 'ReceiveMessage', target,
arguments: [ arguments: [
{ {
ContextId: contextId, ContextId: contextId,
@@ -155,7 +160,8 @@ function buildSignalRJsonInvocation(
function buildSignalRMessagePackInvocation( function buildSignalRMessagePackInvocation(
updateType: number, updateType: number,
messagePayload: Record<string, unknown>, messagePayload: Record<string, unknown>,
contextId: string | null contextId: string | null,
target: string = 'ReceiveMessage'
): Uint8Array { ): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape: // SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments] // [type, headers, invocationId, target, arguments]
@@ -163,7 +169,7 @@ function buildSignalRMessagePackInvocation(
1, 1,
{}, {},
null, null,
'ReceiveMessage', target,
[ [
{ {
ContextId: contextId, ContextId: contextId,
@@ -213,6 +219,20 @@ export class NotificationsHub extends DurableObject<Env> {
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
if (url.pathname === '/internal/auth-request-response' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as {
userId?: string;
authRequestId?: string;
contextId?: string | null;
} | null;
const userId = String(body?.userId || '').trim();
const authRequestId = String(body?.authRequestId || '').trim();
if (!userId || !authRequestId) return new Response('Invalid auth request notification', { status: 400 });
this.broadcastAuthRequestResponse(userId, authRequestId, String(body?.contextId || '').trim() || null);
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/online' && request.method === 'GET') { if (url.pathname === '/internal/online' && request.method === 'GET') {
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), { return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
status: 200, status: 200,
@@ -222,7 +242,7 @@ export class NotificationsHub extends DurableObject<Env> {
}); });
} }
if (url.pathname !== '/notifications/hub') { if (url.pathname !== '/notifications/hub' && url.pathname !== '/notifications/anonymous-hub') {
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
} }
@@ -232,8 +252,13 @@ export class NotificationsHub extends DurableObject<Env> {
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim(); const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null; const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
const requestAuthRequestId = String(url.searchParams.get('nw_auth_request_id') || '').trim() || null;
const isAnonymousAuthRequestHub = url.pathname === '/notifications/anonymous-hub';
if (!requestUserId) { if (!isAnonymousAuthRequestHub && !requestUserId) {
return new Response('Unauthorized', { status: 401 });
}
if (isAnonymousAuthRequestHub && !requestAuthRequestId) {
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
@@ -248,7 +273,9 @@ export class NotificationsHub extends DurableObject<Env> {
this.ctx.acceptWebSocket(server, tags); this.ctx.acceptWebSocket(server, tags);
server.serializeAttachment({ server.serializeAttachment({
userId: requestUserId, kind: isAnonymousAuthRequestHub ? 'anonymous-auth-request' : 'user',
userId: isAnonymousAuthRequestHub ? null : requestUserId,
authRequestId: requestAuthRequestId,
handshakeComplete: false, handshakeComplete: false,
protocol: 'messagepack', protocol: 'messagepack',
deviceIdentifier: requestDeviceIdentifier, deviceIdentifier: requestDeviceIdentifier,
@@ -274,7 +301,6 @@ export class NotificationsHub extends DurableObject<Env> {
attachment.handshakeComplete = true; attachment.handshakeComplete = true;
ws.serializeAttachment(attachment); ws.serializeAttachment(attachment);
ws.send(SIGNALR_HANDSHAKE_ACK); ws.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus(attachment.userId);
return; return;
} catch { } catch {
// Ignore malformed pre-handshake payloads. // Ignore malformed pre-handshake payloads.
@@ -293,26 +319,22 @@ export class NotificationsHub extends DurableObject<Env> {
} }
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> { async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null; void ws;
const shouldBroadcast = !!attachment?.handshakeComplete; void code;
if (shouldBroadcast && attachment?.userId) { void reason;
this.broadcastDeviceStatus(attachment.userId); void wasClean;
}
} }
async webSocketError(ws: WebSocket, error: unknown): Promise<void> { async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null; void ws;
const shouldBroadcast = !!attachment?.handshakeComplete; void error;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
} }
private getOnlineDeviceIdentifiers(): string[] { private getOnlineDeviceIdentifiers(): string[] {
const out = new Set<string>(); const out = new Set<string>();
for (const ws of this.ctx.getWebSockets()) { for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WsAttachment | null; const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue; if (!attachment?.handshakeComplete || attachment.kind !== 'user' || !attachment.deviceIdentifier) continue;
out.add(attachment.deviceIdentifier); out.add(attachment.deviceIdentifier);
} }
return Array.from(out); return Array.from(out);
@@ -349,16 +371,45 @@ export class NotificationsHub extends DurableObject<Env> {
} }
} }
private broadcastDeviceStatus(userId: string): void { private broadcastAuthRequestResponse(userId: string, authRequestId: string, contextId: string | null): void {
this.broadcastMessage( for (const ws of this.ctx.getWebSockets()) {
SIGNALR_UPDATE_TYPE_DEVICE_STATUS, const attachment = ws.deserializeAttachment() as WsAttachment | null;
{ if (
!attachment?.handshakeComplete ||
attachment.kind !== 'anonymous-auth-request' ||
attachment.authRequestId !== authRequestId
) {
continue;
}
const payload = {
UserId: userId, UserId: userId,
Date: new Date().toISOString(), Id: authRequestId,
}, };
null, try {
null if (attachment.protocol === 'json') {
); ws.send(buildSignalRJsonInvocation(
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
payload,
contextId,
'AuthRequestResponseRecieved'
));
} else {
ws.send(buildSignalRMessagePackInvocation(
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
payload,
contextId,
'AuthRequestResponseRecieved'
));
}
} catch {
try {
ws.close(1011, 'Notification send failed');
} catch {
// ignore close races
}
}
}
} }
} }
@@ -392,13 +443,59 @@ export async function getOnlineUserDevices(env: Env, userId: string): Promise<st
} }
} }
export async function notifyAuthRequestResponse(
env: Env,
userId: string,
authRequestId: string,
contextId?: string | null
): Promise<void> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
const stub = env.NOTIFICATIONS_HUB.get(id);
await stub.fetch('https://notifications/internal/auth-request-response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId,
authRequestId,
contextId: contextId || null,
}),
});
} catch (error) {
console.error('Failed to broadcast auth request response notification:', error);
}
}
export function notifyUserAuthRequest(
env: Env,
userId: string,
authRequestId: string,
contextId?: string | null
): void {
waitUntil(notifyUserUpdate(
env,
userId,
SIGNALR_UPDATE_TYPE_AUTH_REQUEST,
new Date().toISOString(),
contextId ?? null,
null,
{
UserId: userId,
Id: authRequestId,
}
));
}
async function notifyUserUpdate( async function notifyUserUpdate(
env: Env, env: Env,
userId: string, userId: string,
updateType: number, updateType: number,
revisionDate: string, revisionDate: string,
contextId: string | null, contextId: string | null,
targetDeviceIdentifier: string | null targetDeviceIdentifier: string | null,
payloadOverride?: Record<string, unknown> | null
): Promise<void> { ): Promise<void> {
try { try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId); const id = env.NOTIFICATIONS_HUB.idFromName(userId);
@@ -414,7 +511,7 @@ async function notifyUserUpdate(
contextId: contextId || null, contextId: contextId || null,
updateType, updateType,
targetDeviceIdentifier: targetDeviceIdentifier || null, targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: { payload: payloadOverride || {
UserId: userId, UserId: userId,
Date: revisionDate, Date: revisionDate,
}, },
+252
View File
@@ -10,6 +10,10 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// CONTRACT: // CONTRACT:
// users.master_password_hash is server-side login verification only. It does // users.master_password_hash is server-side login verification only. It does
// not decrypt vault data. Password changes must keep encrypted user key material, // not decrypt vault data. Password changes must keep encrypted user key material,
@@ -64,6 +68,77 @@ function normalizeTotpSecret(input: string): string {
return out; return out;
} }
function randomBase32Secret(length: number = 32): string {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
let out = '';
for (const byte of bytes) {
out += TOTP_BASE32_ALPHABET[byte % TOTP_BASE32_ALPHABET.length];
}
return out;
}
function base64UrlEncodeBytes(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64UrlDecodeBytes(input: string): Uint8Array {
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
const binary = atob(base64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)));
}
async function createTotpUserVerificationToken(env: Env, user: User, key: string): Promise<string> {
const payload = {
sub: user.id,
key,
stamp: user.securityStamp,
exp: Date.now() + TOTP_USER_VERIFICATION_TOKEN_TTL_MS,
};
const payloadB64 = base64UrlEncodeBytes(new TextEncoder().encode(JSON.stringify(payload)));
const signatureB64 = base64UrlEncodeBytes(await hmacSha256(env.JWT_SECRET, payloadB64));
return `${payloadB64}.${signatureB64}`;
}
async function verifyTotpUserVerificationToken(env: Env, user: User, key: string, token: string): Promise<boolean> {
try {
const [payloadB64, signatureB64] = String(token || '').split('.');
if (!payloadB64 || !signatureB64) return false;
const expected = base64UrlEncodeBytes(await hmacSha256(env.JWT_SECRET, payloadB64));
if (expected !== signatureB64) return false;
const payload = JSON.parse(new TextDecoder().decode(base64UrlDecodeBytes(payloadB64))) as {
sub?: string;
key?: string;
stamp?: string;
exp?: number;
};
return (
payload.sub === user.id &&
payload.key === key &&
payload.stamp === user.securityStamp &&
typeof payload.exp === 'number' &&
payload.exp >= Date.now()
);
} catch {
return false;
}
}
function normalizeRecoveryCodeInput(input: string): string { function normalizeRecoveryCodeInput(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
} }
@@ -91,6 +166,23 @@ async function verifyUserSecret(
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email); return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
} }
function readBodyString(body: Record<string, unknown>, names: string[]): string {
for (const name of names) {
const value = body[name];
if (typeof value === 'string') return value;
}
return '';
}
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
return Object.fromEntries(formData.entries()) as Record<string, unknown>;
}
return await request.json();
}
function toProfile(user: User, env: Env): ProfileResponse { function toProfile(user: User, env: Env): ProfileResponse {
void env; void env;
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
@@ -592,6 +684,164 @@ export async function handleGetTotpStatus(request: Request, env: Env, userId: st
}); });
} }
function twoFactorProviderResponse(type: number, enabled: boolean): Record<string, unknown> {
return {
Enabled: enabled,
Type: type,
Object: 'twoFactorProvider',
};
}
function twoFactorAuthenticatorResponse(
enabled: boolean,
key: string,
userVerificationToken?: string
): Record<string, unknown> {
return {
Enabled: enabled,
Key: key,
UserVerificationToken: userVerificationToken ?? null,
Object: 'twoFactorAuthenticator',
};
}
// GET /api/two-factor
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
const data = user.totpSecret
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)]
: [];
return jsonResponse({
Data: data,
ContinuationToken: null,
Object: 'list',
});
}
// POST /api/two-factor/get-authenticator
export async function handleGetTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const key = normalizeTotpSecret(user.totpSecret || '') || randomBase32Secret();
const userVerificationToken = await createTotpUserVerificationToken(env, user, key);
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
}
// PUT/POST /api/two-factor/authenticator
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
const token = readBodyString(body, ['token', 'Token']).trim();
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!key || !token || !userVerificationToken) {
return errorResponse('Key, token and userVerificationToken are required', 400);
}
if (!await verifyTotpUserVerificationToken(env, user, key, userVerificationToken)) {
return errorResponse('User verification failed.', 400);
}
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400);
user.totpSecret = key;
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
}
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
return errorResponse('Two-factor provider is not supported by this server.', 400);
}
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
let verified = false;
if (key && userVerificationToken) {
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
}
if (!verified) {
verified = await verifyUserSecret(auth, user, secret);
}
if (!verified) return errorResponse('User verification failed.', 400);
user.totpSecret = null;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.disable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false));
}
// PUT /api/accounts/totp // PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" } // enable: { enabled: true, secret: "...", token: "123456" }
// disable: { enabled: false, masterPasswordHash: "..." } // disable: { enabled: false, masterPasswordHash: "..." }
@@ -699,7 +949,9 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user
} }
return jsonResponse({ return jsonResponse({
Code: user.totpRecoveryCode,
code: user.totpRecoveryCode, code: user.totpRecoveryCode,
Object: 'twoFactorRecover',
object: 'twoFactorRecover', object: 'twoFactorRecover',
}); });
} }
+269
View File
@@ -0,0 +1,269 @@
import type { AuthRequestRecord, AuthRequestType, Env } from '../types';
import { StorageService } from '../services/storage';
import { generateUUID } from '../utils/uuid';
import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/device';
import { errorResponse, jsonResponse } from '../utils/response';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0;
const AUTH_REQUEST_TYPE_UNLOCK = 1;
const AUTH_REQUEST_TYPE_ADMIN_APPROVAL = 2;
function normalizeText(value: unknown, maxLength: number): string {
return String(value ?? '').trim().slice(0, maxLength);
}
function getClientIp(request: Request): string | null {
return (
request.headers.get('CF-Connecting-IP') ||
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
null
);
}
function getCountryName(request: Request): string | null {
return request.headers.get('CF-IPCountry') || null;
}
function deviceTypeName(type: number): string {
const names: Record<number, string> = {
0: 'Android',
1: 'iOS',
2: 'Chrome Extension',
3: 'Firefox Extension',
4: 'Opera Extension',
5: 'Edge Extension',
6: 'Windows Desktop',
7: 'macOS Desktop',
8: 'Linux Desktop',
9: 'Chrome',
10: 'Firefox',
11: 'Opera',
12: 'Edge',
13: 'Internet Explorer',
14: 'Unknown Browser',
15: 'Android',
16: 'Windows UWP',
17: 'Safari',
18: 'Vivaldi',
19: 'Vivaldi Extension',
20: 'Safari Extension',
21: 'SDK',
22: 'Server',
23: 'Windows CLI',
24: 'macOS CLI',
25: 'Linux CLI',
26: 'DuckDuckGo',
};
return names[type] || `Device ${type}`;
}
function buildOrigin(request: Request): string {
return new URL(request.url).host;
}
function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord, requestDeviceId?: string | null) {
return {
id: authRequest.id,
Id: authRequest.id,
publicKey: authRequest.publicKey,
PublicKey: authRequest.publicKey,
requestDeviceIdentifier: authRequest.requestDeviceIdentifier,
RequestDeviceIdentifier: authRequest.requestDeviceIdentifier,
requestDeviceTypeValue: authRequest.requestDeviceType,
RequestDeviceTypeValue: authRequest.requestDeviceType,
requestDeviceType: deviceTypeName(authRequest.requestDeviceType),
RequestDeviceType: deviceTypeName(authRequest.requestDeviceType),
requestIpAddress: authRequest.requestIpAddress,
RequestIpAddress: authRequest.requestIpAddress,
requestCountryName: authRequest.requestCountryName,
RequestCountryName: authRequest.requestCountryName,
key: authRequest.key,
Key: authRequest.key,
masterPasswordHash: authRequest.masterPasswordHash,
MasterPasswordHash: authRequest.masterPasswordHash,
creationDate: authRequest.creationDate,
CreationDate: authRequest.creationDate,
responseDate: authRequest.responseDate,
ResponseDate: authRequest.responseDate,
requestApproved: authRequest.approved ?? false,
RequestApproved: authRequest.approved ?? false,
requestDeviceId: requestDeviceId ?? null,
RequestDeviceId: requestDeviceId ?? null,
origin: buildOrigin(request),
Origin: buildOrigin(request),
object: 'auth-request',
Object: 'auth-request',
};
}
function listResponse<T>(data: T[]) {
return {
data,
Data: data,
object: 'list',
Object: 'list',
continuationToken: null,
ContinuationToken: null,
};
}
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
try {
const body = await request.json();
return body && typeof body === 'object' ? body as Record<string, any> : null;
} catch {
return null;
}
}
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
for (const name of names) {
if (body[name] !== undefined) return body[name];
}
return undefined;
}
function isSupportedAuthRequestType(value: number): value is AuthRequestType {
return value === AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK || value === AUTH_REQUEST_TYPE_UNLOCK || value === AUTH_REQUEST_TYPE_ADMIN_APPROVAL;
}
export async function handleCreateAuthRequest(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase();
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
const type = Number.isFinite(requestedType) ? requestedType : AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK;
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
},
request
);
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
}
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
return errorResponse('Invalid auth request type.', 400);
}
const user = await storage.getUser(email);
if (!user || user.status !== 'active') {
return errorResponse('User or known device not found.', 400);
}
await storage.pruneExpiredAuthRequests();
const now = new Date().toISOString();
const authRequest: AuthRequestRecord = {
id: generateUUID(),
userId: user.id,
organizationId: null,
type,
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
requestDeviceType: deviceInfo.deviceType,
requestIpAddress: getClientIp(request),
requestCountryName: getCountryName(request),
responseDeviceIdentifier: null,
accessCode,
publicKey,
key: null,
masterPasswordHash: null,
approved: null,
creationDate: now,
responseDate: null,
authenticationDate: null,
};
await storage.createAuthRequest(authRequest);
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(id);
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleGetAuthRequestResponse(request: Request, env: Env, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const accessCode = normalizeText(url.searchParams.get('code'), 25);
const authRequest = await storage.getAuthRequestById(id);
if (!authRequest || authRequest.accessCode !== accessCode || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleListAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const authRequests = await storage.listAuthRequestsByUserId(userId);
return jsonResponse(listResponse(authRequests.map((authRequest) => toAuthRequestResponse(request, authRequest))));
}
export async function handleListPendingAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
await storage.pruneExpiredAuthRequests();
const authRequests = await storage.listPendingAuthRequestsByUserId(userId);
const rows = await Promise.all(authRequests.map(async (authRequest) => {
const device = await storage.getDevice(userId, authRequest.requestDeviceIdentifier);
return toAuthRequestResponse(request, authRequest, device?.deviceIdentifier ?? authRequest.requestDeviceIdentifier);
}));
return jsonResponse(listResponse(rows));
}
export async function handleUpdateAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const authRequest = await storage.getAuthRequestById(id);
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
if (authRequest.approved !== null || authRequest.responseDate || authRequest.authenticationDate) {
return errorResponse('Auth request has already been answered.', 409);
}
const latestForUser = await storage.listPendingAuthRequestsByUserId(userId);
const latestForDevice = latestForUser.find((item) => item.requestDeviceIdentifier === authRequest.requestDeviceIdentifier);
if (latestForDevice?.id !== authRequest.id) {
return errorResponse('This request is no longer valid. Make sure to approve the most recent request.', 400);
}
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
const responseDeviceIdentifier =
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
readActingDeviceIdentifier(request) ||
'web';
if (approved && !key) {
return errorResponse('Encrypted key is required to approve the request.', 400);
}
const updated = await storage.updateAuthRequestResponse(id, userId, {
approved,
responseDeviceIdentifier,
key,
masterPasswordHash,
});
if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestById(id);
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
if (approved) {
await notifyAuthRequestResponse(env, userId, id);
}
return jsonResponse(toAuthRequestResponse(request, updatedRequest || authRequest));
}
+60 -37
View File
@@ -141,6 +141,12 @@ function optionalEncString(value: unknown): string | null {
return isValidEncString(value) ? value.trim() : null; return isValidEncString(value) ? value.trim() : null;
} }
function optionalEncStringWithin(value: unknown, maxLength: number): string | null {
const normalized = optionalEncString(value);
if (!normalized) return null;
return normalized.length <= maxLength ? normalized : null;
}
function shouldAcceptCipherKey(value: unknown): boolean { function shouldAcceptCipherKey(value: unknown): boolean {
return value == null || value === '' || isValidEncString(value); return value == null || value === '' || isValidEncString(value);
} }
@@ -151,13 +157,16 @@ function normalizeCipherKeyForStorage(value: unknown): string | null {
function sanitizeEncryptedObject<T extends Record<string, any>>( function sanitizeEncryptedObject<T extends Record<string, any>>(
source: T | null | undefined, source: T | null | undefined,
encryptedKeys: readonly string[] encryptedKeys: readonly string[] | Record<string, number>
): T | null { ): T | null {
if (!source || typeof source !== 'object') return source ?? null; if (!source || typeof source !== 'object') return source ?? null;
const next: Record<string, any> = { ...source }; const next: Record<string, any> = { ...source };
for (const key of encryptedKeys) { const entries = Array.isArray(encryptedKeys)
? encryptedKeys.map((key) => [key, 10000] as const)
: Object.entries(encryptedKeys);
for (const [key, maxLength] of entries) {
if (!Object.prototype.hasOwnProperty.call(next, key)) continue; if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
next[key] = optionalEncString(next[key]); next[key] = optionalEncStringWithin(next[key], maxLength);
} }
return next as T; return next as T;
} }
@@ -188,7 +197,12 @@ export function normalizeCipherLoginForCompatibility(
): any { ): any {
const normalized = normalizeCipherLoginForStorage(login); const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null; if (!normalized || typeof normalized !== 'object') return normalized ?? null;
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']); const next = sanitizeEncryptedObject(normalized, {
username: 1000,
password: 5000,
totp: 1000,
uri: 10000,
});
if (!next) return null; if (!next) return null;
next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, { next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
requiresUriChecksum, requiresUriChecksum,
@@ -214,23 +228,19 @@ function normalizeCipherLoginUrisForCompatibility(
const hasChecksum = isValidEncString(next.uriChecksum); const hasChecksum = isValidEncString(next.uriChecksum);
const hasMatch = next.match != null; const hasMatch = next.match != null;
if (hasUri && hasChecksum) { if (hasUri && String(next.uri).trim().length > 10000) continue;
if (hasChecksum && String(next.uriChecksum).trim().length > 10000) {
next.uriChecksum = null;
}
if (hasUri && isValidEncString(next.uriChecksum)) {
out.push(next); out.push(next);
continue; continue;
} }
if (hasUri && !hasChecksum) { if (hasUri && !hasChecksum) {
if (options.preserveRepairableUris) { // Official Bitwarden treats UriChecksum as nullable encrypted metadata.
// Preserve the encrypted URI so NodeWarden Web can decrypt it and repair // Keep the URI intact and let clients that can repair checksums do so.
// the missing checksum. Dropping it here makes the URI appear lost and
// can turn a display-only compatibility issue into data loss on save.
out.push({ ...next, uriChecksum: null });
continue;
}
// Bitwarden browser clients using the SDK drop item-key encrypted URIs
// whose checksum is missing/invalid. User-key encrypted legacy/import
// entries bypass this validation and can safely keep the URI.
if (options.requiresUriChecksum) continue;
out.push({ ...next, uriChecksum: null }); out.push({ ...next, uriChecksum: null });
continue; continue;
} }
@@ -243,14 +253,27 @@ function normalizeCipherLoginUrisForCompatibility(
return out.length ? out : null; return out.length ? out : null;
} }
function hasMissingLoginUriChecksum(cipher: Cipher): boolean { export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): string | null {
if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false; if (cipher.name != null && !optionalEncStringWithin(cipher.name, 1000)) return 'Cipher name must be an encrypted string up to 1000 characters.';
const uris = (cipher.login as any).uris; if (cipher.notes != null && !optionalEncStringWithin(cipher.notes, 10000)) return 'Cipher notes must be an encrypted string up to 10000 characters.';
if (!Array.isArray(uris)) return false;
return uris.some((uri: any) => { const login = cipher.login as any;
if (!uri || typeof uri !== 'object') return false; if (login && typeof login === 'object') {
return isValidEncString(uri.uri) && !isValidEncString(uri.uriChecksum); if (login.username != null && !optionalEncStringWithin(login.username, 1000)) return 'Login username must be an encrypted string up to 1000 characters.';
}); if (login.password != null && !optionalEncStringWithin(login.password, 5000)) return 'Login password must be an encrypted string up to 5000 characters.';
if (login.totp != null && !optionalEncStringWithin(login.totp, 1000)) return 'Login TOTP must be an encrypted string up to 1000 characters.';
if (login.uri != null && !optionalEncStringWithin(login.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
if (Array.isArray(login.uris)) {
for (const uri of login.uris) {
if (!uri || typeof uri !== 'object') continue;
if (uri.uri != null && !optionalEncStringWithin(uri.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
}
}
}
return null;
} }
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null { function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
@@ -589,7 +612,14 @@ export function cipherToResponse(
!!responseCipherKey, !!responseCipherKey,
!!options.preserveRepairableUris !!options.preserveRepairableUris
); );
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']); const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, {
cardholderName: 1000,
brand: 1000,
number: 1000,
expMonth: 1000,
expYear: 1000,
code: 1000,
});
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [ const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
'title', 'title',
'firstName', 'firstName',
@@ -647,6 +677,7 @@ export function cipherToResponse(
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory), passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
key: responseCipherKey, key: responseCipherKey,
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
encryptedFor: (passthrough as any).encryptedFor ?? null, encryptedFor: (passthrough as any).encryptedFor ?? null,
}; };
} }
@@ -772,6 +803,8 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher); normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);
// Prevent referencing a folder owned by another user. // Prevent referencing a folder owned by another user.
if (cipher.folderId) { if (cipher.folderId) {
@@ -779,10 +812,6 @@ 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);
@@ -835,10 +864,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
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);
} }
if (!shouldPreserveRepairableCipherUris(request) && incomingLogin.present && hasMissingLoginUriChecksum(existingCipher)) {
return errorResponse('This item has login URIs that must be repaired in NodeWarden Web before updating from this client. Open NodeWarden Web once, then resync.', 400);
}
const nextType = Number(cipherData.type) || existingCipher.type; const nextType = Number(cipherData.type) || existingCipher.type;
// Opaque passthrough: merge existing stored data with ALL incoming client fields. // Opaque passthrough: merge existing stored data with ALL incoming client fields.
@@ -887,6 +912,8 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.fields = null; cipher.fields = null;
} }
normalizeCipherForStorage(cipher); normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);
// Prevent referencing a folder owned by another user. // Prevent referencing a folder owned by another user.
if (cipher.folderId) { if (cipher.folderId) {
@@ -894,10 +921,6 @@ 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 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);
+52 -18
View File
@@ -19,15 +19,17 @@ import {
assertAccountPasskeyCredential, assertAccountPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption, buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
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;
const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code. // Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
// Keep request parsing backward-compatible with historical provider values (8 / 100). // the official Identity provider enum (RecoveryCode = 8), while request parsing remains
// compatible with older/local provider values.
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function resolveTotpSecret(userSecret: string | null): string | null { function resolveTotpSecret(userSecret: string | null): string | null {
@@ -76,6 +78,14 @@ function constantTimeEquals(a: string, b: string): boolean {
return diff === 0; return diff === 0;
} }
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
for (const name of names) {
const value = body[name];
if (value != null) return value;
}
return undefined;
}
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:'; const isHttps = new URL(request.url).protocol === 'https:';
const parts = [ const parts = [
@@ -130,12 +140,13 @@ function buildPreloginResponse(
}; };
} }
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response { function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
const providers = includeRecoveryCode // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE] // Clients expose recovery-code entry points themselves; Android 2026.4 fails to
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]; // parse the challenge if an unknown recovery provider key such as "8" is included.
const providers2: Record<string, null> = {}; const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
for (const provider of providers) providers2[provider] = null; const providers2: Record<string, { Email: null }> = {};
for (const provider of providers) providers2[provider] = { Email: null };
const customResponse = { const customResponse = {
TwoFactorProviders: providers, TwoFactorProviders: providers,
TwoFactorProviders2: providers2, TwoFactorProviders2: providers2,
@@ -228,9 +239,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Login with password // Login with password
const email = body.username?.toLowerCase(); const email = body.username?.toLowerCase();
const passwordHash = body.password; const passwordHash = body.password;
const twoFactorToken = body.twoFactorToken; const authRequestId = readBodyValue(body, ['authRequest', 'AuthRequest']);
const twoFactorProvider = body.twoFactorProvider; const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
const twoFactorRemember = body.twoFactorRemember; const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
const loginIdentifier = clientIdentifier; const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
@@ -272,11 +284,31 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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); let validatedAuthRequestId: string | null = null;
let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
valid = !!(
authRequest &&
authRequest.userId === user.id &&
authRequest.type === 0 &&
authRequest.approved === true &&
authRequest.responseDate &&
!authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) &&
constantTimeEquals(authRequest.accessCode, passwordHash)
);
if (valid) {
validatedAuthRequestId = authRequest!.id;
}
} else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
}
if (!valid) { if (!valid) {
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: user.id, actorUserId: user.id,
action: 'auth.login.failed.bad_password', action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
category: 'auth', category: 'auth',
level: 'warn', level: 'warn',
targetType: 'user', targetType: 'user',
@@ -298,7 +330,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
let trustedTwoFactorTokenToReturn: string | undefined; let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret); const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) { if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
@@ -308,7 +339,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing, // Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload. // respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) { if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode); return twoFactorRequiredResponse('Two factor required.');
} }
let passedByRememberToken = false; let passedByRememberToken = false;
@@ -323,7 +354,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow. // Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) { if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode); return twoFactorRequiredResponse('Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
@@ -332,7 +363,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
} else if ( } else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE || normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) || normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST) normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
) { ) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) { if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
@@ -375,6 +406,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Successful login - clear failed attempts // Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
if (validatedAuthRequestId) {
await storage.markAuthRequestAuthenticated(validatedAuthRequestId);
}
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
+5 -1
View File
@@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers'; import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers';
// Bitwarden client import request format // Bitwarden client import request format
interface CiphersImportRequest { interface CiphersImportRequest {
@@ -252,6 +252,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.login = normalizeCipherLoginForStorage(cipher.login);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) {
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
}
cipherRows.push(cipher); cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id }); cipherMapRows.push({ index: i, sourceId, id: cipher.id });
+15
View File
@@ -56,3 +56,18 @@ export async function handleNotificationsHub(request: Request, env: Env): Promis
} }
return stub.fetch(new Request(forwardedUrl.toString(), request)); return stub.fetch(new Request(forwardedUrl.toString(), request));
} }
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
if (!authRequestId) return errorResponse('Token is required', 400);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const forwardedUrl = new URL(request.url);
forwardedUrl.searchParams.set('nw_auth_request_id', authRequestId);
return stub.fetch(new Request(forwardedUrl.toString(), request));
}
+44 -2
View File
@@ -11,6 +11,10 @@ import {
handleGetTotpStatus, handleGetTotpStatus,
handleSetTotpStatus, handleSetTotpStatus,
handleGetTotpRecoveryCode, handleGetTotpRecoveryCode,
handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator,
handleDisableTwoFactorProvider,
handleGetApiKey, handleGetApiKey,
handleRotateApiKey, handleRotateApiKey,
} from './handlers/accounts'; } from './handlers/accounts';
@@ -74,6 +78,12 @@ import {
handleGetAccountPasskeyUpdateAssertionOptions, handleGetAccountPasskeyUpdateAssertionOptions,
handleUpdateAccountPasskeyEncryption, handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys'; } from './handlers/account-passkeys';
import {
handleGetAuthRequest,
handleListAuthRequests,
handleListPendingAuthRequests,
handleUpdateAuthRequest,
} from './handlers/auth-requests';
export async function handleAuthenticatedRoute( export async function handleAuthenticatedRoute(
request: Request, request: Request,
@@ -119,6 +129,25 @@ export async function handleAuthenticatedRoute(
return handleGetTotpRecoveryCode(request, env, userId); return handleGetTotpRecoveryCode(request, env, userId);
} }
if (path === '/api/two-factor') {
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
return handleGetTwoFactorAuthenticator(request, env, userId);
}
if (path === '/api/two-factor/authenticator') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
return handleDisableTwoFactorProvider(request, env, userId);
}
if (path === '/api/accounts/revision-date' && method === 'GET') { if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId); return handleGetRevisionDate(request, env, userId);
} }
@@ -262,8 +291,21 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId); if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
} }
if (path.startsWith('/api/auth-requests')) { if (path === '/api/auth-requests' || path === '/api/auth-requests/') {
return jsonResponse({ data: [], object: 'list', continuationToken: null }); if (method === 'GET') return handleListAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/auth-requests/pending') {
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
const authRequestMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)$/i);
if (authRequestMatch) {
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
return errorResponse('Method not allowed', 405);
} }
if (path === '/api/collections' || path.startsWith('/api/collections/')) { if (path === '/api/collections' || path.startsWith('/api/collections/')) {
+22
View File
@@ -15,9 +15,14 @@ import {
handleGetPasswordHint, handleGetPasswordHint,
handleRecoverTwoFactor, handleRecoverTwoFactor,
} from './handlers/accounts'; } from './handlers/accounts';
import {
handleCreateAuthRequest,
handleGetAuthRequestResponse,
} from './handlers/auth-requests';
import { handlePublicDownloadAttachment } from './handlers/attachments'; import { handlePublicDownloadAttachment } from './handlers/attachments';
import { handlePublicUploadAttachment } from './handlers/attachments'; import { handlePublicUploadAttachment } from './handlers/attachments';
import { import {
handleAnonymousNotificationsHub,
handleNotificationsHub, handleNotificationsHub,
handleNotificationsNegotiate, handleNotificationsNegotiate,
} from './handlers/notifications'; } from './handlers/notifications';
@@ -390,6 +395,19 @@ export async function handlePublicRoute(
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]); return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
} }
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleCreateAuthRequest(request, env);
}
const authRequestResponseMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)\/response$/i);
if (authRequestResponseMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleGetAuthRequestResponse(request, env, authRequestResponseMatch[1]);
}
if (path === '/identity/connect/token' && method === 'POST') { if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env); return handleToken(request, env);
} }
@@ -477,5 +495,9 @@ export async function handlePublicRoute(
if (path === '/notifications/hub' && method === 'GET') { if (path === '/notifications/hub' && method === 'GET') {
return handleNotificationsHub(request, env); return handleNotificationsHub(request, env);
} }
if (path === '/notifications/anonymous-hub' && method === 'GET') {
return handleAnonymousNotificationsHub(request, env);
}
return null; return null;
} }
+139
View File
@@ -0,0 +1,139 @@
import type { AuthRequestRecord, AuthRequestType } from '../types';
const AUTH_REQUEST_EXPIRATION_MS = 15 * 60 * 1000;
function mapAuthRequestRow(row: any): AuthRequestRecord {
return {
id: row.id,
userId: row.user_id,
organizationId: row.organization_id ?? null,
type: Number(row.type) as AuthRequestType,
requestDeviceIdentifier: row.request_device_identifier,
requestDeviceType: Number(row.request_device_type ?? 14),
requestIpAddress: row.request_ip_address ?? null,
requestCountryName: row.request_country_name ?? null,
responseDeviceIdentifier: row.response_device_identifier ?? null,
accessCode: row.access_code,
publicKey: row.public_key,
key: row.key ?? null,
masterPasswordHash: row.master_password_hash ?? null,
approved: row.approved == null ? null : Number(row.approved) === 1,
creationDate: row.creation_date,
responseDate: row.response_date ?? null,
authenticationDate: row.authentication_date ?? null,
};
}
export function isAuthRequestExpired(request: AuthRequestRecord, nowMs: number = Date.now()): boolean {
return new Date(request.creationDate).getTime() + AUTH_REQUEST_EXPIRATION_MS <= nowMs;
}
const AUTH_REQUEST_SELECT =
'SELECT id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date ' +
'FROM auth_requests';
export async function createAuthRequest(db: D1Database, request: AuthRequestRecord): Promise<void> {
await db
.prepare(
'INSERT INTO auth_requests(' +
'id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(
request.id,
request.userId,
request.organizationId,
request.type,
request.requestDeviceIdentifier,
request.requestDeviceType,
request.requestIpAddress,
request.requestCountryName,
request.responseDeviceIdentifier,
request.accessCode,
request.publicKey,
request.key,
request.masterPasswordHash,
request.approved == null ? null : (request.approved ? 1 : 0),
request.creationDate,
request.responseDate,
request.authenticationDate
)
.run();
}
export async function getAuthRequestById(db: D1Database, id: string): Promise<AuthRequestRecord | null> {
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? LIMIT 1`).bind(id).first<any>();
return row ? mapAuthRequestRow(row) : null;
}
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
return (res.results || []).map(mapAuthRequestRow);
}
export async function listPendingAuthRequestsByUserId(db: D1Database, userId: string, nowMs: number = Date.now()): Promise<AuthRequestRecord[]> {
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
const res = await db
.prepare(
'SELECT ar.id, ar.user_id, ar.organization_id, ar.type, ar.request_device_identifier, ar.request_device_type, ar.request_ip_address, ar.request_country_name, ' +
'ar.response_device_identifier, ar.access_code, ar.public_key, ar.key, ar.master_password_hash, ar.approved, ar.creation_date, ar.response_date, ar.authentication_date ' +
'FROM auth_requests ar ' +
'JOIN (' +
' SELECT request_device_identifier, MAX(creation_date) AS latest_creation_date ' +
' FROM auth_requests ' +
' WHERE user_id = ? AND type IN (0, 1) AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL AND creation_date >= ? ' +
' GROUP BY request_device_identifier' +
') latest ON latest.request_device_identifier = ar.request_device_identifier AND latest.latest_creation_date = ar.creation_date ' +
'WHERE ar.user_id = ? AND ar.type IN (0, 1) AND ar.approved IS NULL AND ar.response_date IS NULL AND ar.authentication_date IS NULL ' +
'ORDER BY ar.creation_date DESC'
)
.bind(userId, cutoff, userId)
.all<any>();
return (res.results || []).map(mapAuthRequestRow).filter((request) => !isAuthRequestExpired(request, nowMs));
}
export async function updateAuthRequestResponse(
db: D1Database,
id: string,
userId: string,
update: {
approved: boolean;
responseDeviceIdentifier: string;
key?: string | null;
masterPasswordHash?: string | null;
responseDate?: string;
}
): Promise<boolean> {
const result = await db
.prepare(
'UPDATE auth_requests SET approved = ?, response_device_identifier = ?, key = ?, master_password_hash = ?, response_date = ? ' +
'WHERE id = ? AND user_id = ? AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL'
)
.bind(
update.approved ? 1 : 0,
update.responseDeviceIdentifier,
update.approved ? (update.key ?? null) : null,
update.approved ? (update.masterPasswordHash ?? null) : null,
update.responseDate || new Date().toISOString(),
id,
userId
)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function markAuthRequestAuthenticated(db: D1Database, id: string, authenticationDate: string = new Date().toISOString()): Promise<boolean> {
const result = await db
.prepare('UPDATE auth_requests SET authentication_date = ? WHERE id = ? AND authentication_date IS NULL')
.bind(authenticationDate, id)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function pruneExpiredAuthRequests(db: D1Database, nowMs: number = Date.now()): Promise<number> {
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
const result = await db.prepare('DELETE FROM auth_requests WHERE creation_date < ?').bind(cutoff).run();
return Number(result.meta.changes ?? 0);
}
+9
View File
@@ -109,6 +109,15 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
'request_ip_address TEXT, request_country_name TEXT, response_device_identifier TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, key TEXT, master_password_hash TEXT, ' +
'approved INTEGER, creation_date TEXT NOT NULL, response_date TEXT, authentication_date TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' + 'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
+56 -8
View File
@@ -1,4 +1,4 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types'; import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { ensureStorageSchema } from './storage-schema'; import { ensureStorageSchema } from './storage-schema';
import { import {
@@ -103,6 +103,15 @@ import {
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice, updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
} from './storage-device-repo'; } from './storage-device-repo';
import {
createAuthRequest as createStoredAuthRequest,
getAuthRequestById as findStoredAuthRequestById,
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
pruneExpiredAuthRequests as pruneStoredExpiredAuthRequests,
updateAuthRequestResponse as updateStoredAuthRequestResponse,
} from './storage-auth-request-repo';
import { import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
@@ -134,8 +143,8 @@ 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-06-09-account-passkeys'; const STORAGE_SCHEMA_VERSION = '2026-06-12-auth-requests';
const REQUIRED_ACCOUNT_PASSKEY_TABLES = ['webauthn_credentials', 'webauthn_challenges'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -166,14 +175,14 @@ export class StorageService {
return stmt.bind(...values.map(v => v === undefined ? null : v)); return stmt.bind(...values.map(v => v === undefined ? null : v));
} }
private async hasAccountPasskeyTables(): Promise<boolean> { private async hasRequiredSchemaTables(): Promise<boolean> {
const placeholders = REQUIRED_ACCOUNT_PASSKEY_TABLES.map(() => '?').join(', '); const placeholders = REQUIRED_SCHEMA_TABLES.map(() => '?').join(', ');
const result = await this.db const result = await this.db
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`) .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
.bind(...REQUIRED_ACCOUNT_PASSKEY_TABLES) .bind(...REQUIRED_SCHEMA_TABLES)
.all<{ name: string }>(); .all<{ name: string }>();
const found = new Set((result.results || []).map((row) => row.name)); const found = new Set((result.results || []).map((row) => row.name));
return REQUIRED_ACCOUNT_PASSKEY_TABLES.every((table) => found.has(table)); return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
} }
private sqlChunkSize(fixedBindCount: number): number { private sqlChunkSize(fixedBindCount: number): number {
@@ -220,7 +229,7 @@ export class StorageService {
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run(); await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY); const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
? !(await this.hasAccountPasskeyTables()) ? !(await this.hasRequiredSchemaTables())
: true; : true;
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) { if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
await ensureStorageSchema(this.db); await ensureStorageSchema(this.db);
@@ -716,6 +725,45 @@ export class StorageService {
return deleteStoredDevicesByUserId(this.db, userId); return deleteStoredDevicesByUserId(this.db, userId);
} }
// --- Auth requests / Login with device ---
async createAuthRequest(request: AuthRequestRecord): Promise<void> {
await createStoredAuthRequest(this.db, request);
}
async getAuthRequestById(id: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestById(this.db, id);
}
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredAuthRequestsByUserId(this.db, userId);
}
async listPendingAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredPendingAuthRequestsByUserId(this.db, userId);
}
async updateAuthRequestResponse(
id: string,
userId: string,
update: {
approved: boolean;
responseDeviceIdentifier: string;
key?: string | null;
masterPasswordHash?: string | null;
}
): Promise<boolean> {
return updateStoredAuthRequestResponse(this.db, id, userId, update);
}
async markAuthRequestAuthenticated(id: string): Promise<boolean> {
return markStoredAuthRequestAuthenticated(this.db, id);
}
async pruneExpiredAuthRequests(): Promise<number> {
return pruneStoredExpiredAuthRequests(this.db);
}
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> { async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
return listStoredTrustedTokenSummaries(this.db, userId); return listStoredTrustedTokenSummaries(this.db, userId);
} }
+22
View File
@@ -273,6 +273,28 @@ export interface DevicePendingAuthRequest {
creationDate: string; creationDate: string;
} }
export type AuthRequestType = 0 | 1 | 2;
export interface AuthRequestRecord {
id: string;
userId: string;
organizationId: string | null;
type: AuthRequestType;
requestDeviceIdentifier: string;
requestDeviceType: number;
requestIpAddress: string | null;
requestCountryName: string | null;
responseDeviceIdentifier: string | null;
accessCode: string;
publicKey: string;
key: string | null;
masterPasswordHash: string | null;
approved: boolean | null;
creationDate: string;
responseDate: string | null;
authenticationDate: string | null;
}
export interface DeviceResponse { export interface DeviceResponse {
id: string; id: string;
userId?: string | null; userId?: string | null;
+96 -3
View File
@@ -3,6 +3,7 @@ import { useLocation } from 'wouter';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell'; import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays'; import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
import AuthRequestApprovalDialog from '@/components/AuthRequestApprovalDialog';
import AuthViews from '@/components/AuthViews'; import AuthViews from '@/components/AuthViews';
import NotFoundPage from '@/components/NotFoundPage'; import NotFoundPage from '@/components/NotFoundPage';
import PublicSendPage from '@/components/PublicSendPage'; import PublicSendPage from '@/components/PublicSendPage';
@@ -22,6 +23,12 @@ import {
saveSession, saveSession,
stripProfileSecrets, stripProfileSecrets,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import {
encryptSessionUserKeyForAuthRequest,
isPendingAuthRequest,
listPendingAuthRequests,
respondToAuthRequest,
} from '@/lib/api/auth-requests';
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } 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';
@@ -74,7 +81,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, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthRequest, 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 {
@@ -94,6 +101,8 @@ const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.fil
const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_HOME_ROUTE = '/settings';
const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules'; const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const; const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
const APP_ROUTE_PATHS = [ const APP_ROUTE_PATHS = [
'/', '/',
@@ -102,7 +111,8 @@ const APP_ROUTE_PATHS = [
'/sends', '/sends',
'/admin', '/admin',
'/logs', '/logs',
'/security/devices', LEGACY_DEVICE_MANAGEMENT_ROUTE,
DEVICE_MANAGEMENT_ROUTE,
'/backup', '/backup',
'/settings', '/settings',
SETTINGS_ACCOUNT_ROUTE, SETTINGS_ACCOUNT_ROUTE,
@@ -213,6 +223,8 @@ export default function App() {
const [disableTotpOpen, setDisableTotpOpen] = useState(false); const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false); const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
@@ -1060,6 +1072,52 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
const pendingAuthRequestsQuery = useQuery({
queryKey: ['auth-requests-pending', vaultCacheKey || session?.email],
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
staleTime: 5_000,
refetchInterval: 15_000,
refetchIntervalInBackground: true,
});
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId;
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (!session) throw new Error(t('txt_vault_key_unavailable'));
setAuthRequestSubmittingId(authRequest.id);
try {
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
await respondToAuthRequest(authedFetch, authRequest.id, {
key,
masterPasswordHash: null,
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: true,
});
setAuthRequestDialogDismissedId(null);
pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch();
} finally {
setAuthRequestSubmittingId(null);
}
}
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestSubmittingId(authRequest.id);
try {
await respondToAuthRequest(authedFetch, authRequest.id, {
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: false,
});
setAuthRequestDialogDismissedId(null);
pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch();
} finally {
setAuthRequestSubmittingId(null);
}
}
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> { function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains); const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains); const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
@@ -1509,7 +1567,7 @@ export default function App() {
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 === '/logs') return t('nav_log_center');
if (location === '/security/devices') return t('nav_device_management'); if (location === LEGACY_DEVICE_MANAGEMENT_ROUTE || location === DEVICE_MANAGEMENT_ROUTE) 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');
if (isImportRoute) return t('nav_import_export'); if (isImportRoute) return t('nav_import_export');
@@ -1518,6 +1576,16 @@ export default function App() {
return t('nav_my_vault'); return t('nav_my_vault');
})(); })();
useEffect(() => {
if (phase !== 'app') return;
if (!hashPath.startsWith('/')) return;
if (normalizedHashPath !== DEVICE_MANAGEMENT_ROUTE && normalizedHashPath !== LEGACY_DEVICE_MANAGEMENT_ROUTE) return;
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
window.history.replaceState(null, '', DEVICE_MANAGEMENT_ROUTE);
}
if (location !== DEVICE_MANAGEMENT_ROUTE) navigate(DEVICE_MANAGEMENT_ROUTE);
}, [phase, hashPath, normalizedHashPath, location, navigate]);
useEffect(() => { useEffect(() => {
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
}, [phase, location, isPublicSendRoute, navigate]); }, [phase, location, isPublicSendRoute, navigate]);
@@ -1624,6 +1692,13 @@ export default function App() {
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey, onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock, onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey, onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
pendingAuthRequests,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch();
},
onApproveAuthRequest: approveAuthRequest,
onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes, onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction, onSessionTimeoutActionChange: setSessionTimeoutAction,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
@@ -1868,6 +1943,24 @@ export default function App() {
}} }}
disableTotpSubmitting={disableTotpSubmitting} disableTotpSubmitting={disableTotpSubmitting}
/> />
<AuthRequestApprovalDialog
open={authRequestDialogOpen}
authRequest={latestPendingAuthRequest}
submitting={!!authRequestSubmittingId}
onApprove={() => {
if (!latestPendingAuthRequest) return;
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onDeny={() => {
if (!latestPendingAuthRequest) return;
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)}
/>
</> </>
); );
} }
@@ -47,6 +47,9 @@ function isAdminProfile(profile: Profile | null): boolean {
return String(profile?.role || '').toLowerCase() === 'admin'; return String(profile?.role || '').toLowerCase() === 'admin';
} }
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
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 isDomainRulesRoute = props.location === '/settings/domain-rules';
@@ -55,7 +58,8 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
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' || props.location === '/logs'; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const managementActive = props.location === '/admin' || deviceManagementActive || 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);
@@ -177,7 +181,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
{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'))} {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(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
</> </>
); );
@@ -222,7 +226,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<> <>
{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'))} {isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))} {renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</> </>
)} )}
</> </>
+46 -28
View File
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
import type { AuditLogFilters } from '@/lib/api/admin'; 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 { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, 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'));
@@ -116,6 +116,11 @@ export interface AppMainRoutesProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onRefreshAuthorizedDevices: () => Promise<void>; onRefreshAuthorizedDevices: () => Promise<void>;
@@ -153,6 +158,7 @@ export interface AppMainRoutesProps {
export default function AppMainRoutes(props: AppMainRoutesProps) { export default function AppMainRoutes(props: AppMainRoutesProps) {
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const; const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
const deviceManagementRoutePaths = ['/security/devices', '/settings/security/device-management'] as const;
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin'; const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
const importPageContent = ( const importPageContent = (
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
@@ -269,6 +275,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreateAccountPasskey={props.onCreateAccountPasskey} onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock} onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey} onDeleteAccountPasskey={props.onDeleteAccountPasskey}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onLockTimeoutChange={props.onLockTimeoutChange} onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
@@ -287,7 +298,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsIcon size={18} /> <SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span> <span>{t('nav_account_settings')}</span>
</Link> </Link>
<Link href="/security/devices" className="mobile-settings-link"> <Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} /> <Shield size={18} />
<span>{t('nav_device_management')}</span> <span>{t('nav_device_management')}</span>
</Link> </Link>
@@ -327,32 +338,39 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<LoadingState card lines={4} /> <LoadingState card lines={4} />
) : null} ) : null}
</Route> </Route>
<Route path="/security/devices"> {deviceManagementRoutePaths.map((path) => (
<div className="stack"> <Route key={path} path={path}>
{props.mobileLayout && ( <div className="stack">
<div className="mobile-settings-subhead"> {props.mobileLayout && (
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}> <div className="mobile-settings-subhead">
<span className="btn-icon" aria-hidden="true">{"<"}</span> <button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
{t('txt_back')} <span className="btn-icon" aria-hidden="true">{"<"}</span>
</button> {t('txt_back')}
</div> </button>
)} </div>
<Suspense fallback={<RouteContentFallback />}> )}
<SecurityDevicesPage <Suspense fallback={<RouteContentFallback />}>
devices={props.authorizedDevices} <SecurityDevicesPage
loading={props.authorizedDevicesLoading} devices={props.authorizedDevices}
error={props.authorizedDevicesError} loading={props.authorizedDevicesLoading}
onRefresh={() => void props.onRefreshAuthorizedDevices()} error={props.authorizedDevicesError}
onRenameDevice={props.onRenameAuthorizedDevice} pendingAuthRequests={props.pendingAuthRequests}
onRevokeTrust={props.onRevokeDeviceTrust} pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onTrustPermanently={props.onTrustDevicePermanently} onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRemoveDevice={props.onRemoveDevice} onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onRevokeAll={props.onRevokeAllDeviceTrust} onApproveAuthRequest={props.onApproveAuthRequest}
onRemoveAll={props.onRemoveAllDevices} onDenyAuthRequest={props.onDenyAuthRequest}
/> onRenameDevice={props.onRenameAuthorizedDevice}
</Suspense> onRevokeTrust={props.onRevokeDeviceTrust}
</div> onTrustPermanently={props.onTrustDevicePermanently}
</Route> onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices}
/>
</Suspense>
</div>
</Route>
))}
<Route path="/settings/domain-rules"> <Route path="/settings/domain-rules">
<div className="stack domain-rules-route"> <div className="stack domain-rules-route">
{props.mobileLayout && ( {props.mobileLayout && (
@@ -0,0 +1,74 @@
import { ShieldCheck, ShieldX } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import { t } from '@/lib/i18n';
import type { AuthRequest } from '@/lib/types';
interface AuthRequestApprovalDialogProps {
open: boolean;
authRequest: AuthRequest | null;
submitting: boolean;
onApprove: () => void;
onDeny: () => void;
onClose: () => void;
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
export default function AuthRequestApprovalDialog(props: AuthRequestApprovalDialogProps) {
const authRequest = props.authRequest;
return (
<ConfirmDialog
open={props.open && !!authRequest}
title={t('txt_approve_device_login')}
message={t('txt_auth_request_approve_message')}
confirmText={props.submitting ? t('txt_approving') : t('txt_approve')}
cancelText={t('txt_later')}
confirmDisabled={props.submitting || !authRequest}
cancelDisabled={props.submitting}
onConfirm={props.onApprove}
onCancel={props.onClose}
afterActions={(
<button
type="button"
className="btn btn-danger dialog-btn"
disabled={props.submitting || !authRequest}
onClick={props.onDeny}
>
<ShieldX size={14} className="btn-icon" />
{t('txt_deny')}
</button>
)}
>
{authRequest && (
<div className="auth-request-details">
<div className="auth-request-device">
<ShieldCheck size={18} />
<div>
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
<small>{authRequest.requestDeviceIdentifier}</small>
</div>
</div>
<div className="auth-request-kv">
<span>{t('txt_created')}</span>
<strong>{formatDateTime(authRequest.creationDate)}</strong>
</div>
{authRequest.requestIpAddress && (
<div className="auth-request-kv">
<span>{t('txt_ip_address')}</span>
<strong>{authRequest.requestIpAddress}</strong>
</div>
)}
<div className="auth-request-fingerprint">
<span>{t('txt_fingerprint_phrase')}</span>
<strong>{authRequest.fingerprintPhrase || t('txt_dash')}</strong>
</div>
</div>
)}
</ConfirmDialog>
);
}
+40
View File
@@ -0,0 +1,40 @@
export function CardSkeleton() {
return (
<div className="skeleton-card">
<div className="skeleton-avatar" />
<div className="skeleton-content">
<div className="skeleton-line skeleton-line-lg" />
<div className="skeleton-line" />
</div>
</div>
);
}
export function ListSkeleton({ count = 5 }: { count?: number }) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="skeleton-list-item">
<div className="skeleton-icon" />
<div className="skeleton-content">
<div className="skeleton-line skeleton-line-md" />
<div className="skeleton-line skeleton-line-sm" />
</div>
</div>
))}
</>
);
}
export function PageSkeleton() {
return (
<div className="skeleton-page">
<div className="skeleton-header">
<div className="skeleton-line skeleton-line-xl" />
</div>
<div className="skeleton-body">
<ListSkeleton />
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { useState } from 'preact/hooks';
import { RefreshCw, ShieldCheck, ShieldX } from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import type { AuthRequest } from '@/lib/types';
import { t } from '@/lib/i18n';
interface PendingAuthRequestsPanelProps {
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
className?: string;
loadingVariant?: 'placeholder' | 'compact';
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const date = new Date(value);
return Number.isNaN(date.getTime()) ? t('txt_dash') : date.toLocaleString();
}
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (authRequestSubmittingId) return;
setAuthRequestSubmittingId(authRequest.id);
try {
await props.onApproveAuthRequest(authRequest);
} finally {
setAuthRequestSubmittingId(null);
}
}
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
if (authRequestSubmittingId) return;
setAuthRequestSubmittingId(authRequest.id);
try {
await props.onDenyAuthRequest(authRequest);
} finally {
setAuthRequestSubmittingId(null);
}
}
return (
<section className={props.className || 'card settings-module'}>
<div className="settings-module-head">
<h3>{t('txt_pending_device_logins')}</h3>
<button
type="button"
className="btn btn-secondary small"
disabled={props.pendingAuthRequestsLoading}
onClick={() => void props.onRefreshPendingAuthRequests()}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
<div className="account-passkeys-list">
{props.pendingAuthRequestsLoading && props.pendingAuthRequests.length === 0 ? (
props.loadingVariant === 'compact' ? (
<LoadingState lines={2} compact />
) : (
<div className="settings-module-placeholder">
<RefreshCw size={20} />
<span>{t('txt_loading')}</span>
</div>
)
) : props.pendingAuthRequests.length === 0 ? (
<div className="settings-module-placeholder">
<ShieldCheck size={20} />
<span>{t('txt_no_pending_device_logins')}</span>
</div>
) : (
props.pendingAuthRequests.map((authRequest) => (
<div key={authRequest.id} className="account-passkey-row auth-request-row">
<div className="account-passkey-main">
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
<small>{authRequest.requestDeviceIdentifier}</small>
<small>{t('txt_created_value', { value: formatDateTime(authRequest.creationDate) })}</small>
</div>
<span className="auth-request-fingerprint-inline">
{authRequest.fingerprintPhrase || t('txt_dash')}
</span>
<div className="actions account-passkey-actions">
<button
type="button"
className="btn btn-primary small"
disabled={!!authRequestSubmittingId}
onClick={() => void approveAuthRequest(authRequest)}
>
<ShieldCheck size={14} className="btn-icon" />
{authRequestSubmittingId === authRequest.id ? t('txt_approving') : t('txt_approve')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={!!authRequestSubmittingId}
onClick={() => void denyAuthRequest(authRequest)}
>
<ShieldX size={14} className="btn-icon" />
{t('txt_deny')}
</button>
</div>
</div>
))
)}
</div>
</section>
);
}
+17 -1
View File
@@ -2,14 +2,20 @@ import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldCheck, 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 PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
import type { AuthRequest, AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps { interface SecurityDevicesPageProps {
devices: AuthorizedDevice[]; devices: AuthorizedDevice[];
loading: boolean; loading: boolean;
error: string; error: string;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefresh: () => void; onRefresh: () => void;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<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; onTrustPermanently: (device: AuthorizedDevice) => void;
@@ -72,6 +78,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
return ( return (
<> <>
<div className="stack"> <div className="stack">
<PendingAuthRequestsPanel
className="card"
loadingVariant="compact"
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="card"> <section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
+22 -8
View File
@@ -2,9 +2,10 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { AccountPasskeyCredential, Profile } from '@/lib/types'; import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n'; import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
@@ -22,6 +23,11 @@ interface SettingsPageProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -76,6 +82,13 @@ function clearLegacyTotpSetupSecrets(): void {
} }
} }
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const date = new Date(value);
if (Number.isNaN(date.getTime())) return t('txt_dash');
return date.toLocaleString();
}
export default function SettingsPage(props: SettingsPageProps) { export default function SettingsPage(props: SettingsPageProps) {
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
@@ -219,13 +232,6 @@ export default function SettingsPage(props: SettingsPageProps) {
return t('txt_prf_not_supported'); return t('txt_prf_not_supported');
} }
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
async function changeLocale(next: Locale): Promise<void> { async function changeLocale(next: Locale): Promise<void> {
if (next === getLocale()) return; if (next === getLocale()) return;
setSelectedLocale(next); setSelectedLocale(next);
@@ -504,6 +510,14 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
</section> </section>
<PendingAuthRequestsPanel
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="settings-module sensitive-actions-module"> <section className="settings-module sensitive-actions-module">
<div className="sensitive-actions-grid"> <div className="sensitive-actions-grid">
<div className="sensitive-action"> <div className="sensitive-action">
+128
View File
@@ -0,0 +1,128 @@
import { base64ToBytes, bytesToBase64, hkdfExpand, toBufferSource } from '@/lib/crypto';
import { EFFLongWordList } from '@/lib/fingerprint-wordlist';
import { t } from '@/lib/i18n';
import type { AuthRequest, ListResponse, SessionState } from '@/lib/types';
import type { AuthedFetch } from './shared';
import { parseErrorMessage, parseJson } from './shared';
function readResponseProperty<T>(source: Record<string, any>, camel: string, pascal: string, fallback: T): T {
return (source[camel] ?? source[pascal] ?? fallback) as T;
}
function normalizeAuthRequest(raw: Record<string, any>): AuthRequest {
return {
id: String(readResponseProperty(raw, 'id', 'Id', '')),
publicKey: String(readResponseProperty(raw, 'publicKey', 'PublicKey', '')),
requestDeviceType: readResponseProperty(raw, 'requestDeviceType', 'RequestDeviceType', null),
requestDeviceTypeValue: readResponseProperty(raw, 'requestDeviceTypeValue', 'RequestDeviceTypeValue', null),
requestDeviceIdentifier: String(readResponseProperty(raw, 'requestDeviceIdentifier', 'RequestDeviceIdentifier', '')),
requestIpAddress: readResponseProperty(raw, 'requestIpAddress', 'RequestIpAddress', null),
requestCountryName: readResponseProperty(raw, 'requestCountryName', 'RequestCountryName', null),
key: readResponseProperty(raw, 'key', 'Key', null),
creationDate: String(readResponseProperty(raw, 'creationDate', 'CreationDate', '')),
requestApproved: readResponseProperty(raw, 'requestApproved', 'RequestApproved', null),
responseDate: readResponseProperty(raw, 'responseDate', 'ResponseDate', null),
deviceId: readResponseProperty(raw, 'deviceId', 'DeviceId', null),
requestDeviceId: readResponseProperty(raw, 'requestDeviceId', 'RequestDeviceId', null),
};
}
async function withFingerprintPhrase(email: string, request: AuthRequest): Promise<AuthRequest> {
if (!request.publicKey) return request;
try {
return {
...request,
fingerprintPhrase: await getFingerprintPhrase(email, base64ToBytes(request.publicKey)),
};
} catch {
return request;
}
}
export async function listPendingAuthRequests(authedFetch: AuthedFetch, email: string): Promise<AuthRequest[]> {
const resp = await authedFetch('/api/auth-requests/pending');
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_auth_requests_load_failed')));
const body = await parseJson<ListResponse<Record<string, any>> & { Data?: Record<string, any>[] }>(resp);
const rows = (body?.data || body?.Data || []).map(normalizeAuthRequest);
return Promise.all(rows.map((row) => withFingerprintPhrase(email, row)));
}
export async function respondToAuthRequest(
authedFetch: AuthedFetch,
requestId: string,
payload: {
key?: string | null;
masterPasswordHash?: string | null;
deviceIdentifier: string;
requestApproved: boolean;
}
): Promise<AuthRequest> {
const resp = await authedFetch(`/api/auth-requests/${encodeURIComponent(requestId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_auth_request_update_failed')));
const body = await parseJson<Record<string, any>>(resp);
if (!body) throw new Error(t('txt_auth_request_update_failed'));
return normalizeAuthRequest(body);
}
export function isPendingAuthRequest(request: AuthRequest): boolean {
if (!request.id || !request.creationDate) return false;
if (request.responseDate) return false;
const createdAt = new Date(request.creationDate).getTime();
if (!Number.isFinite(createdAt)) return true;
return Date.now() - createdAt < 15 * 60 * 1000;
}
export async function encryptSessionUserKeyForAuthRequest(session: SessionState, authRequest: AuthRequest): Promise<string> {
if (!session.symEncKey || !session.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
if (!authRequest.publicKey) throw new Error(t('txt_auth_request_missing_public_key'));
const userKeyBytes = new Uint8Array(64);
userKeyBytes.set(base64ToBytes(session.symEncKey), 0);
userKeyBytes.set(base64ToBytes(session.symMacKey), 32);
const publicKey = await crypto.subtle.importKey(
'spki',
toBufferSource(base64ToBytes(authRequest.publicKey)),
{ name: 'RSA-OAEP', hash: 'SHA-1' },
false,
['encrypt']
);
const encryptedBytes = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
toBufferSource(userKeyBytes)
));
return `4.${bytesToBase64(encryptedBytes)}`;
}
export async function getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string> {
const keyFingerprint = new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(publicKey)));
const userFingerprint = await hkdfExpand(keyFingerprint, email.toLowerCase(), 32);
return hashPhrase(userFingerprint).join('-');
}
function hashPhrase(hash: Uint8Array, minimumEntropy = 64): string[] {
const entropyPerWord = Math.log(EFFLongWordList.length) / Math.log(2);
let numWords = Math.ceil(minimumEntropy / entropyPerWord);
if (numWords * entropyPerWord > hash.length * 4) {
throw new Error('Output entropy of hash function is too small');
}
let hashNumber = 0n;
for (const byte of hash) {
hashNumber = (hashNumber * 256n) + BigInt(byte);
}
const phrase: string[] = [];
const wordCount = BigInt(EFFLongWordList.length);
while (numWords > 0) {
const remainder = Number(hashNumber % wordCount);
hashNumber /= wordCount;
phrase.push(EFFLongWordList[remainder]);
numWords -= 1;
}
return phrase;
}
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1172,7 +1172,22 @@ const en: Record<string, string> = {
"txt_target": "Target", "txt_target": "Target",
"txt_time": "Time", "txt_time": "Time",
"txt_time_range": "Time range", "txt_time_range": "Time range",
"txt_remove_domain": "Remove domain" "txt_remove_domain": "Remove domain",
"txt_approve_device_login": "Approve device login",
"txt_auth_request_approve_message": "Unlock Bitwarden on your device or approve from the web app. Before approving, make sure the fingerprint phrase matches the one below.",
"txt_approve": "Approve",
"txt_approving": "Approving...",
"txt_deny": "Deny",
"txt_later": "Later",
"txt_pending_device_logins": "Pending device logins",
"txt_no_pending_device_logins": "No pending device logins",
"txt_fingerprint_phrase": "Fingerprint phrase",
"txt_auth_requests_load_failed": "Failed to load device login requests",
"txt_auth_request_update_failed": "Failed to update device login request",
"txt_auth_request_approved": "Device login approved",
"txt_auth_request_denied": "Device login denied",
"txt_auth_request_missing_public_key": "Device login request is missing a public key",
"txt_ip_address": "IP address"
}; };
export default en; export default en;
+16 -1
View File
@@ -1172,7 +1172,22 @@ const es: Record<string, string> = {
"txt_target": "Destino", "txt_target": "Destino",
"txt_time": "Hora", "txt_time": "Hora",
"txt_time_range": "Rango de tiempo", "txt_time_range": "Rango de tiempo",
"txt_remove_domain": "Quitar dominio" "txt_remove_domain": "Quitar dominio",
"txt_approve_device_login": "Aprobar inicio de sesión con dispositivo",
"txt_auth_request_approve_message": "Desbloquee Bitwarden en su dispositivo o apruebe desde la aplicación web. Antes de aprobar, asegúrese de que la frase de huella coincida con la siguiente.",
"txt_fingerprint_phrase": "Frase de huella",
"txt_ip_address": "Dirección IP",
"txt_approve": "Aprobar",
"txt_approving": "Aprobando...",
"txt_deny": "Denegar",
"txt_later": "Más tarde",
"txt_pending_device_logins": "Inicios de sesión con dispositivo pendientes",
"txt_no_pending_device_logins": "No hay inicios de sesión con dispositivo pendientes",
"txt_auth_requests_load_failed": "No se pudieron cargar las solicitudes de inicio de sesión con dispositivo",
"txt_auth_request_update_failed": "No se pudo actualizar la solicitud de inicio de sesión con dispositivo",
"txt_auth_request_approved": "Inicio de sesión con dispositivo aprobado",
"txt_auth_request_denied": "Inicio de sesión con dispositivo denegado",
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
}; };
export default es; export default es;
+16 -1
View File
@@ -1172,7 +1172,22 @@ const ru: Record<string, string> = {
"txt_target": "Цель", "txt_target": "Цель",
"txt_time": "Время", "txt_time": "Время",
"txt_time_range": "Период", "txt_time_range": "Период",
"txt_remove_domain": "Удалить домен" "txt_remove_domain": "Удалить домен",
"txt_approve_device_login": "Подтвердить вход с устройства",
"txt_auth_request_approve_message": "Разблокируйте Bitwarden на устройстве или подтвердите вход через веб-приложение. Перед подтверждением убедитесь, что фраза отпечатка совпадает с указанной ниже.",
"txt_fingerprint_phrase": "Фраза отпечатка",
"txt_ip_address": "IP-адрес",
"txt_approve": "Подтвердить",
"txt_approving": "Подтверждение...",
"txt_deny": "Отклонить",
"txt_later": "Позже",
"txt_pending_device_logins": "Ожидающие входы с устройств",
"txt_no_pending_device_logins": "Нет ожидающих входов с устройств",
"txt_auth_requests_load_failed": "Не удалось загрузить запросы входа с устройств",
"txt_auth_request_update_failed": "Не удалось обновить запрос входа с устройства",
"txt_auth_request_approved": "Вход с устройства подтвержден",
"txt_auth_request_denied": "Вход с устройства отклонен",
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
}; };
export default ru; export default ru;
+16 -1
View File
@@ -1172,7 +1172,22 @@ const zhCN: Record<string, string> = {
"txt_target": "目标", "txt_target": "目标",
"txt_time": "时间", "txt_time": "时间",
"txt_time_range": "时间范围", "txt_time_range": "时间范围",
"txt_remove_domain": "移除域名" "txt_remove_domain": "移除域名",
"txt_approve_device_login": "批准设备登录",
"txt_auth_request_approve_message": "解锁您设备上的 Bitwarden,或通过网页 App 批准。批准前,请确保指纹短语与下面的相匹配。",
"txt_approve": "批准",
"txt_approving": "正在批准...",
"txt_deny": "拒绝",
"txt_later": "稍后",
"txt_pending_device_logins": "待处理设备登录",
"txt_no_pending_device_logins": "没有待处理设备登录",
"txt_fingerprint_phrase": "指纹短语",
"txt_auth_requests_load_failed": "加载设备登录请求失败",
"txt_auth_request_update_failed": "更新设备登录请求失败",
"txt_auth_request_approved": "已批准设备登录",
"txt_auth_request_denied": "已拒绝设备登录",
"txt_auth_request_missing_public_key": "设备登录请求缺少公钥",
"txt_ip_address": "IP 地址"
}; };
export default zhCN; export default zhCN;
+16 -1
View File
@@ -1172,7 +1172,22 @@ const zhTW: Record<string, string> = {
"txt_target": "目標", "txt_target": "目標",
"txt_time": "時間", "txt_time": "時間",
"txt_time_range": "時間範圍", "txt_time_range": "時間範圍",
"txt_remove_domain": "移除域名" "txt_remove_domain": "移除域名",
"txt_approve_device_login": "批准裝置登入",
"txt_auth_request_approve_message": "解鎖您裝置上的 Bitwarden,或透過網頁 App 批准。批准前,請確保指紋短語與下面的相符。",
"txt_fingerprint_phrase": "指紋短語",
"txt_ip_address": "IP 位址",
"txt_approve": "批准",
"txt_approving": "正在批准...",
"txt_deny": "拒絕",
"txt_later": "稍後",
"txt_pending_device_logins": "待處理裝置登入",
"txt_no_pending_device_logins": "沒有待處理裝置登入",
"txt_auth_requests_load_failed": "載入裝置登入請求失敗",
"txt_auth_request_update_failed": "更新裝置登入請求失敗",
"txt_auth_request_approved": "已批准裝置登入",
"txt_auth_request_denied": "已拒絕裝置登入",
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
}; };
export default zhTW; export default zhTW;
+17
View File
@@ -338,6 +338,23 @@ export interface AccountPasskeyCredential {
revisionDate?: string; revisionDate?: string;
} }
export interface AuthRequest {
id: string;
publicKey: string;
requestDeviceType?: string | null;
requestDeviceTypeValue?: number | null;
requestDeviceIdentifier: string;
requestIpAddress?: string | null;
requestCountryName?: string | null;
key?: string | null;
creationDate: string;
requestApproved?: boolean | null;
responseDate?: string | null;
deviceId?: string | null;
requestDeviceId?: string | null;
fingerprintPhrase?: string;
}
export interface AccountPasskeyAssertionOptionsResponse { export interface AccountPasskeyAssertionOptionsResponse {
options: PublicKeyCredentialRequestOptions; options: PublicKeyCredentialRequestOptions;
token: string; token: string;
+1
View File
@@ -7,6 +7,7 @@
@import './styles/management.css'; @import './styles/management.css';
@import './styles/overlays.css'; @import './styles/overlays.css';
@import './styles/motion.css'; @import './styles/motion.css';
@import './styles/skeleton.css';
@import './styles/responsive.css'; @import './styles/responsive.css';
@import './styles/dark.css'; @import './styles/dark.css';
+31 -2
View File
@@ -8,7 +8,14 @@ body,
@apply m-0 h-full w-full p-0; @apply m-0 h-full w-full p-0;
color: var(--text); color: var(--text);
background: var(--bg-accent); background: var(--bg-accent);
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
font-size: var(--font-base);
line-height: var(--leading-normal);
letter-spacing: var(--tracking-normal);
font-feature-settings: 'liga' 1, 'kern' 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
html { html {
@@ -16,7 +23,7 @@ html {
} }
body { body {
@apply relative antialiased; @apply relative;
transition: background-color var(--dur-medium) var(--ease-smooth), color var(--dur-medium) var(--ease-smooth); transition: background-color var(--dur-medium) var(--ease-smooth), color var(--dur-medium) var(--ease-smooth);
} }
@@ -25,6 +32,28 @@ body.dialog-open {
overscroll-behavior: contain; overscroll-behavior: contain;
} }
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
margin: 0;
}
h1 { font-size: var(--font-4xl); }
h2 { font-size: var(--font-3xl); }
h3 { font-size: var(--font-xl); }
h4 { font-size: var(--font-lg); }
p {
margin: 0;
line-height: var(--leading-relaxed);
}
small {
font-size: var(--font-sm);
line-height: var(--leading-snug);
}
::selection { ::selection {
background: color-mix(in srgb, var(--primary) 20%, transparent); background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--text); color: var(--text);
+81 -20
View File
@@ -1,20 +1,33 @@
.muted { .muted {
@apply m-0 mb-4 text-center leading-relaxed text-muted; @apply m-0 mb-4 text-center text-muted;
font-size: var(--font-sm);
line-height: var(--leading-relaxed);
letter-spacing: var(--tracking-normal);
} }
.field { .field {
@apply mb-3.5 block; @apply mb-4 block;
} }
.field > span { .field > span {
@apply mb-2 mt-2.5 block text-sm font-semibold; @apply mb-2 block;
font-size: var(--font-sm);
font-weight: 600;
letter-spacing: var(--tracking-wide);
color: var(--muted-strong);
line-height: var(--leading-snug);
} }
.input { .input {
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base leading-normal text-ink outline-none transition; @apply h-12 w-full rounded-xl border px-3.5 py-2.5 outline-none;
background: var(--panel); background: var(--panel);
border-color: rgba(74, 103, 150, 0.34); border-color: rgba(74, 103, 150, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
transition: all var(--dur-fast) var(--ease-smooth);
font-size: var(--font-base);
line-height: var(--leading-snug);
letter-spacing: var(--tracking-normal);
color: var(--text);
} }
select.input { select.input {
@@ -54,9 +67,10 @@ input[type='file'].input::file-selector-button:hover {
} }
.input:focus { .input:focus {
border-color: rgba(43, 102, 217, 0.6); border-color: var(--primary);
background-color: #fbfdff; background-color: #fbfdff;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.10), 0 8px 18px rgba(37, 99, 235, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.95); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12), 0 8px 20px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
} }
.input-readonly { .input-readonly {
@@ -115,7 +129,12 @@ input[type='file'].input::file-selector-button:hover {
} }
.btn { .btn {
@apply inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-full border border-transparent px-4 text-[15px] font-bold no-underline transition; @apply inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-full border border-transparent px-4 no-underline;
font-size: var(--font-sm);
font-weight: 600;
letter-spacing: var(--tracking-wide);
line-height: 1;
transition: all var(--dur-fast) var(--ease-smooth);
} }
.topbar-actions .btn, .topbar-actions .btn,
@@ -161,28 +180,53 @@ input[type='file'].input::file-selector-button:hover {
} }
.btn.full { .btn.full {
@apply my-2.5 h-12 w-full text-lg; @apply my-2.5 h-12 w-full;
font-size: var(--font-md);
font-weight: 600;
} }
.btn-primary { .btn-primary {
@apply border-blue-700/30 bg-blue-600 text-white; @apply text-white;
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.20); background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
border: 1px solid rgba(37, 99, 235, 0.4);
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
}
.btn-primary::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, transparent 50%);
opacity: 0;
transition: opacity var(--dur-fast) var(--ease-smooth);
} }
.btn-primary:hover { .btn-primary:hover {
@apply bg-blue-700; background: linear-gradient(135deg, #1d4ed8 0%, #1e3a8a 100%);
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.22); box-shadow: 0 6px 20px rgba(37, 99, 235, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
}
.btn-primary:hover::before {
opacity: 1;
}
.btn-primary:active:not(:disabled) {
transform: translateY(0) scale(0.98);
} }
.btn-secondary { .btn-secondary {
@apply bg-panel text-brand-strong; @apply bg-panel text-brand-strong;
border-color: rgba(37, 99, 235, 0.20); border: 1px solid rgba(37, 99, 235, 0.22);
box-shadow: 0 6px 14px rgba(13, 31, 68, 0.04); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.8);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #f4f8ff; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-color: rgba(37, 99, 235, 0.34); border-color: rgba(37, 99, 235, 0.40);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15), inset 0 1px 0 rgba(255, 255, 255, 1);
} }
.btn-danger { .btn-danger {
@@ -200,11 +244,21 @@ input[type='file'].input::file-selector-button:hover {
} }
.or { .or {
@apply text-center text-slate-700; @apply text-center;
font-size: var(--font-sm);
color: var(--muted);
line-height: var(--leading-snug);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
font-weight: 600;
} }
.field-help { .field-help {
@apply mt-2 text-[13px] leading-normal text-slate-500; @apply mt-2;
font-size: var(--font-xs);
line-height: var(--leading-relaxed);
letter-spacing: var(--tracking-normal);
color: var(--muted);
} }
.check-line-compact { .check-line-compact {
@@ -216,14 +270,21 @@ input[type='file'].input::file-selector-button:hover {
} }
.auth-link-btn { .auth-link-btn {
@apply cursor-pointer border-0 bg-transparent p-0 text-[13px] font-bold text-blue-700 transition; @apply cursor-pointer border-0 bg-transparent p-0 transition;
font-size: var(--font-xs);
font-weight: 600;
letter-spacing: var(--tracking-wide);
color: var(--primary-strong);
line-height: var(--leading-snug);
} }
.auth-link-btn:hover { .auth-link-btn:hover {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px;
transform: translateX(2px); transform: translateX(2px);
} }
.auth-link-btn:disabled { .auth-link-btn:disabled {
@apply cursor-not-allowed text-slate-400 no-underline; @apply cursor-not-allowed no-underline;
color: var(--muted);
} }
+20
View File
@@ -64,6 +64,26 @@
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@keyframes shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(37, 99, 235, 0.3); }
50% { box-shadow: 0 0 30px rgba(37, 99, 235, 0.5), 0 0 40px rgba(37, 99, 235, 0.2); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+119 -13
View File
@@ -3,10 +3,15 @@
} }
.app-shell { .app-shell {
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft shadow-elevated; @apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft;
height: calc(100vh - 40px); height: calc(100vh - 40px);
border-color: var(--line); border-color: var(--line);
@apply rounded-3xl; @apply rounded-3xl;
box-shadow:
0 20px 60px rgba(15, 23, 42, 0.12),
0 8px 24px rgba(15, 23, 42, 0.08),
0 0 0 1px rgba(15, 23, 42, 0.04);
transition: box-shadow var(--dur-medium) var(--ease-smooth);
} }
.topbar { .topbar {
@@ -104,7 +109,7 @@
} }
.theme-switch-slider::before { .theme-switch-slider::before {
@apply absolute h-[26px] w-[26px] rounded-full; @apply absolute h-[23px] w-[26px] rounded-full;
content: ''; content: '';
left: 2px; left: 2px;
bottom: 2px; bottom: 2px;
@@ -119,8 +124,8 @@
.theme-switch .sun svg { .theme-switch .sun svg {
@apply absolute h-[18px] w-[18px]; @apply absolute h-[18px] w-[18px];
top: 6px; top: 5px;
left: 32px; left: 29px;
z-index: 1; z-index: 1;
opacity: 0.95; opacity: 0.95;
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth); transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
@@ -193,7 +198,13 @@
} }
.side-link { .side-link {
@apply flex items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-sm font-semibold text-muted-strong no-underline transition; @apply flex items-center rounded-xl border border-transparent px-3 py-2.5 no-underline transition;
gap: var(--space-2);
font-size: var(--font-sm);
font-weight: 500;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-normal);
color: var(--muted-strong);
} }
.side-link span, .side-link span,
@@ -213,24 +224,33 @@
} }
.side-group-trigger { .side-group-trigger {
@apply flex w-full cursor-pointer items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-left text-sm font-semibold text-muted-strong transition; @apply flex w-full cursor-pointer items-center rounded-xl border border-transparent px-3 py-2.5 text-left transition;
gap: var(--space-2);
background: transparent; background: transparent;
font-size: var(--font-sm);
font-weight: 500;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-normal);
color: var(--muted-strong);
} }
.side-link:hover, .side-link:hover,
.side-group-trigger:hover { .side-group-trigger:hover {
background: #fff; background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
border-color: rgba(128, 152, 192, 0.18); border-color: rgba(128, 152, 192, 0.20);
color: var(--text); color: var(--text);
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06), inset 0 1px 0 rgba(255, 255, 255, 1);
transform: translateX(2px);
} }
.side-link.active, .side-link.active,
.side-group-trigger.active { .side-group-trigger.active {
background: rgba(37, 99, 235, 0.11); background: linear-gradient(135deg, rgba(37, 99, 235, 0.12) 0%, rgba(37, 99, 235, 0.08) 100%);
border-color: rgba(37, 99, 235, 0.28); border-color: rgba(37, 99, 235, 0.32);
color: var(--primary-strong); color: var(--primary-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58); box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 2px 8px rgba(37, 99, 235, 0.15);
} }
.side-group-chevron { .side-group-chevron {
@@ -264,7 +284,13 @@
} }
.side-sub-link { .side-sub-link {
@apply flex items-center gap-2 rounded-lg border border-transparent px-2.5 py-2 text-sm font-semibold text-muted no-underline transition; @apply flex items-center rounded-lg border border-transparent px-2.5 py-2 no-underline transition;
gap: var(--space-2);
font-size: var(--font-sm);
font-weight: 500;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-normal);
color: var(--muted);
} }
.side-sub-link:hover { .side-sub-link:hover {
@@ -363,3 +389,83 @@
.mobile-sidebar-head { .mobile-sidebar-head {
@apply hidden; @apply hidden;
} }
.auth-request-details {
display: grid;
gap: 12px;
margin: 12px 0 4px;
}
.auth-request-device {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line-soft);
border-radius: 8px;
padding: 10px;
background: color-mix(in srgb, var(--primary) 7%, var(--panel));
}
.auth-request-device svg {
color: var(--primary-strong);
flex: 0 0 auto;
}
.auth-request-device div,
.account-passkey-main {
min-width: 0;
}
.auth-request-device strong,
.auth-request-device small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-request-device small {
color: var(--muted);
}
.auth-request-kv {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: var(--font-sm);
}
.auth-request-kv span,
.auth-request-fingerprint span {
color: var(--muted);
}
.auth-request-kv strong {
color: var(--text);
text-align: right;
}
.auth-request-fingerprint {
display: grid;
gap: 6px;
border: 1px solid var(--line-soft);
border-radius: 8px;
padding: 10px;
}
.auth-request-fingerprint strong,
.auth-request-fingerprint-inline {
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: var(--font-sm);
line-height: 1.45;
}
.auth-request-row {
align-items: start;
}
.auth-request-fingerprint-inline {
color: var(--muted-strong);
max-width: 260px;
}
+75
View File
@@ -0,0 +1,75 @@
.skeleton-card,
.skeleton-list-item {
@apply flex items-center gap-3 rounded-xl border p-4;
background: var(--panel);
border-color: var(--line-soft);
animation: skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.skeleton-avatar {
@apply h-12 w-12 shrink-0 rounded-full;
background: linear-gradient(
90deg,
var(--panel-muted) 0%,
var(--panel-soft) 50%,
var(--panel-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-icon {
@apply h-10 w-10 shrink-0 rounded-lg;
background: linear-gradient(
90deg,
var(--panel-muted) 0%,
var(--panel-soft) 50%,
var(--panel-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-content {
@apply min-w-0 flex-1 space-y-2;
}
.skeleton-line {
@apply h-3 rounded-full;
background: linear-gradient(
90deg,
var(--panel-muted) 0%,
var(--panel-soft) 50%,
var(--panel-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-line-sm {
@apply w-1/3;
}
.skeleton-line-md {
@apply w-2/3;
}
.skeleton-line-lg {
@apply w-4/5;
}
.skeleton-line-xl {
@apply w-full;
}
.skeleton-page {
@apply h-full space-y-4 p-4;
}
.skeleton-header {
@apply mb-6;
}
.skeleton-body {
@apply space-y-3;
}
+77 -35
View File
@@ -7,66 +7,108 @@
--surface: #ffffff; --surface: #ffffff;
--line: rgba(100, 116, 139, 0.24); --line: rgba(100, 116, 139, 0.24);
--line-soft: rgba(100, 116, 139, 0.14); --line-soft: rgba(100, 116, 139, 0.14);
--text: #111827; --text: #0f172a;
--text-muted: #64748b; --text-muted: #64748b;
--muted: #64748b; --muted: #64748b;
--muted-strong: #334155; --muted-strong: #334155;
--primary: #2457c5; --primary: #2563eb;
--primary-hover: #1d4aa7; --primary-hover: #1d4ed8;
--primary-strong: #173f8f; --primary-strong: #1e40af;
--brand: var(--primary); --brand: var(--primary);
--brand-strong: var(--primary-strong); --brand-strong: var(--primary-strong);
--accent: #0f766e; --accent: #0d9488;
--accent-soft: #e6f6f3; --accent-soft: #e6f6f3;
--danger: #c92f4e; --danger: #dc2626;
--success: #0f766e; --success: #059669;
--warning: #b7791f; --warning: #d97706;
--overlay-strong: rgba(15, 23, 42, 0.58); --overlay-strong: rgba(15, 23, 42, 0.62);
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.045); --shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 8px 18px rgba(15, 23, 42, 0.075); --shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -1px rgba(15, 23, 42, 0.05);
--shadow-lg: 0 18px 44px rgba(15, 23, 42, 0.105); --shadow-lg: 0 20px 25px -5px rgba(15, 23, 42, 0.10), 0 10px 10px -5px rgba(15, 23, 42, 0.04);
--shadow-xl: 0 25px 50px -12px rgba(15, 23, 42, 0.25);
--shadow-glow: 0 0 20px rgba(37, 99, 235, 0.15), 0 8px 24px rgba(37, 99, 235, 0.12);
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 10px; --radius-lg: 10px;
--radius-xl: 14px; --radius-xl: 14px;
--radius-2xl: 18px;
--ease-out-strong: cubic-bezier(0.22, 1, 0.36, 1); --ease-out-strong: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1); --ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1);
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
--dur-instant: 80ms; --dur-instant: 80ms;
--dur-quick: 120ms; --dur-quick: 120ms;
--dur-fast: 180ms; --dur-fast: 180ms;
--dur-medium: 240ms; --dur-medium: 240ms;
--dur-panel: 280ms; --dur-panel: 280ms;
--dur-slow: 350ms;
--actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px); --actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px);
/* Typography Scale */
--font-xs: 11px;
--font-sm: 13px;
--font-base: 14px;
--font-md: 15px;
--font-lg: 16px;
--font-xl: 18px;
--font-2xl: 20px;
--font-3xl: 24px;
--font-4xl: 28px;
/* Line Heights */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-loose: 1.75;
/* Letter Spacing */
--tracking-tighter: -0.02em;
--tracking-tight: -0.01em;
--tracking-normal: 0;
--tracking-wide: 0.01em;
--tracking-wider: 0.02em;
/* Spacing Scale */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
} }
:root[data-theme='dark'] { :root[data-theme='dark'] {
--bg-accent: #101418; --bg-accent: #0a0e13;
--panel: #171d25; --panel: #151b24;
--panel-soft: #131922; --panel-soft: #111720;
--panel-muted: #0f151d; --panel-muted: #0d1219;
--panel-subtle: #1c2430; --panel-subtle: #1a2230;
--surface: #171d25; --surface: #151b24;
--line: rgba(148, 163, 184, 0.18); --line: rgba(148, 163, 184, 0.16);
--line-soft: rgba(148, 163, 184, 0.11); --line-soft: rgba(148, 163, 184, 0.09);
--text: #e8edf4; --text: #f1f5f9;
--text-muted: #9aa8ba; --text-muted: #94a3b8;
--muted: #9aa8ba; --muted: #94a3b8;
--muted-strong: #c4cfdc; --muted-strong: #cbd5e1;
--primary: #80b6ff; --primary: #60a5fa;
--primary-hover: #a6cbff; --primary-hover: #93c5fd;
--primary-strong: #d7e8ff; --primary-strong: #bfdbfe;
--brand: var(--primary); --brand: var(--primary);
--brand-strong: var(--primary-strong); --brand-strong: var(--primary-strong);
--accent: #5eead4; --accent: #2dd4bf;
--accent-soft: rgba(94, 234, 212, 0.12); --accent-soft: rgba(45, 212, 191, 0.12);
--danger: #fb7185; --danger: #f87171;
--success: #5eead4; --success: #34d399;
--warning: #fbbf24; --warning: #fbbf24;
--overlay-strong: rgba(2, 6, 23, 0.74); --overlay-strong: rgba(0, 0, 0, 0.75);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.26); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.30); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.45), 0 2px 4px -1px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 14px 38px rgba(0, 0, 0, 0.34); --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.50), 0 10px 10px -5px rgba(0, 0, 0, 0.40);
--shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.60);
--shadow-glow: 0 0 20px rgba(96, 165, 250, 0.20), 0 8px 24px rgba(96, 165, 250, 0.15);
} }
+39 -8
View File
@@ -21,7 +21,13 @@
} }
.sidebar-title { .sidebar-title {
@apply mb-2 text-[13px] font-bold text-slate-700; @apply mb-2;
font-size: var(--font-xs);
font-weight: 700;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--muted);
line-height: var(--leading-tight);
} }
.sidebar-title-row { .sidebar-title-row {
@@ -86,17 +92,25 @@
} }
.tree-btn { .tree-btn {
@apply mb-1 flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left transition; @apply mb-1 flex w-full min-w-0 cursor-pointer items-center border-0 bg-transparent px-2.5 py-2 text-left transition;
gap: var(--space-2);
border-radius: var(--radius-md);
font-size: var(--font-sm);
font-weight: 500;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-normal);
color: var(--muted-strong);
} }
.tree-btn:hover { .tree-btn:hover {
background: rgba(37, 99, 235, 0.05); background: rgba(37, 99, 235, 0.05);
color: var(--text);
} }
.tree-btn.active { .tree-btn.active {
background: rgba(37, 99, 235, 0.09); background: rgba(37, 99, 235, 0.09);
color: var(--primary-strong); color: var(--primary-strong);
font-weight: 700; font-weight: 600;
} }
.tree-icon { .tree-icon {
@@ -191,8 +205,12 @@
} }
.list-count { .list-count {
@apply shrink-0 whitespace-nowrap text-xs; @apply shrink-0 whitespace-nowrap;
color: var(--text-muted); font-size: var(--font-xs);
font-weight: 600;
letter-spacing: var(--tracking-wide);
color: var(--muted);
line-height: var(--leading-tight);
} }
.list-icon-btn { .list-icon-btn {
@@ -525,7 +543,12 @@
} }
.list-title { .list-title {
@apply flex min-w-0 items-center gap-1.5 text-[15px] font-bold; @apply flex min-w-0 items-center;
gap: var(--space-2);
font-size: var(--font-base);
font-weight: 600;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--primary-strong); color: var(--primary-strong);
transition: color var(--dur-fast) var(--ease-smooth), letter-spacing 220ms var(--ease-out-soft); transition: color var(--dur-fast) var(--ease-smooth), letter-spacing 220ms var(--ease-out-soft);
} }
@@ -569,7 +592,7 @@
.list-item:hover .list-title, .list-item:hover .list-title,
.list-item.active .list-title { .list-item.active .list-title {
letter-spacing: -0.012em; letter-spacing: var(--tracking-tighter);
} }
.list-item:hover .list-sub, .list-item:hover .list-sub,
@@ -614,11 +637,19 @@
.detail-title { .detail-title {
@apply m-0 overflow-hidden text-ellipsis whitespace-nowrap; @apply m-0 overflow-hidden text-ellipsis whitespace-nowrap;
font-size: var(--font-2xl);
font-weight: 600;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tighter);
color: var(--text);
} }
.detail-sub { .detail-sub {
@apply mt-2; @apply mt-2;
color: #667085; font-size: var(--font-sm);
line-height: var(--leading-relaxed);
letter-spacing: var(--tracking-normal);
color: var(--muted);
} }
.password-history-link { .password-history-link {