mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
13 Commits
v1.6.0
..
f6169b7610
| Author | SHA1 | Date | |
|---|---|---|---|
| f6169b7610 | |||
| 493f901ec1 | |||
| b4dfb0409b | |||
| a06cb0ed71 | |||
| b0242265f4 | |||
| b444c0f4b8 | |||
| b1b25fe678 | |||
| 7cf2ab7c88 | |||
| 1918735520 | |||
| c652cc1533 | |||
| e9aef72df7 | |||
| 9adb24d4bb | |||
| 563570e3e0 |
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.6.0",
|
"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 @@
|
|||||||
export const APP_VERSION = '1.6.0';
|
export const APP_VERSION = '1.6.1';
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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 });
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/')) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface BackupPayload {
|
|||||||
ciphers: SqlRow[];
|
ciphers: SqlRow[];
|
||||||
attachments: SqlRow[];
|
attachments: SqlRow[];
|
||||||
webauthn_credentials?: SqlRow[];
|
webauthn_credentials?: SqlRow[];
|
||||||
|
trusted_two_factor_device_tokens?: SqlRow[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +303,7 @@ export function validateBackupPayloadContents(
|
|||||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
||||||
|
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
|
||||||
const externalAttachmentKeys = new Set<string>(
|
const externalAttachmentKeys = new Set<string>(
|
||||||
options.allowExternalAttachmentBlobs
|
options.allowExternalAttachmentBlobs
|
||||||
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
@@ -390,6 +392,21 @@ export function validateBackupPayloadContents(
|
|||||||
accountPasskeyIds.add(id);
|
accountPasskeyIds.add(id);
|
||||||
accountPasskeyCredentialIds.add(credentialId);
|
accountPasskeyCredentialIds.add(credentialId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trustedTwoFactorTokens = new Set<string>();
|
||||||
|
for (const row of trustedTwoFactorTokenRows) {
|
||||||
|
const token = String(row.token || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const deviceIdentifier = String(row.device_identifier || '').trim();
|
||||||
|
const expiresAt = Number(row.expires_at || 0);
|
||||||
|
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
||||||
|
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
|
||||||
|
}
|
||||||
|
if (trustedTwoFactorTokens.has(token)) {
|
||||||
|
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
|
||||||
|
}
|
||||||
|
trustedTwoFactorTokens.add(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildBackupArchive(
|
export async function buildBackupArchive(
|
||||||
@@ -408,7 +425,7 @@ export async function buildBackupArchive(
|
|||||||
includeAttachments,
|
includeAttachments,
|
||||||
});
|
});
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([
|
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
|
||||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||||
@@ -417,6 +434,7 @@ export async function buildBackupArchive(
|
|||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
@@ -445,6 +463,7 @@ export async function buildBackupArchive(
|
|||||||
ciphers: cipherRows.length,
|
ciphers: cipherRows.length,
|
||||||
attachments: exportedAttachmentRows.length,
|
attachments: exportedAttachmentRows.length,
|
||||||
webauthn_credentials: accountPasskeyRows.length,
|
webauthn_credentials: accountPasskeyRows.length,
|
||||||
|
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
|
||||||
},
|
},
|
||||||
includes: {
|
includes: {
|
||||||
attachments: includeAttachments,
|
attachments: includeAttachments,
|
||||||
@@ -468,6 +487,7 @@ export async function buildBackupArchive(
|
|||||||
ciphers: cipherRows,
|
ciphers: cipherRows,
|
||||||
attachments: exportedAttachmentRows,
|
attachments: exportedAttachmentRows,
|
||||||
webauthn_credentials: accountPasskeyRows,
|
webauthn_credentials: accountPasskeyRows,
|
||||||
|
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
|
||||||
}, null, BACKUP_JSON_INDENT)),
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type BackupTableName =
|
|||||||
| 'users'
|
| 'users'
|
||||||
| 'domain_settings'
|
| 'domain_settings'
|
||||||
| 'user_revisions'
|
| 'user_revisions'
|
||||||
|
| 'trusted_two_factor_device_tokens'
|
||||||
| 'webauthn_credentials'
|
| 'webauthn_credentials'
|
||||||
| 'folders'
|
| 'folders'
|
||||||
| 'ciphers'
|
| 'ciphers'
|
||||||
@@ -34,6 +35,7 @@ const BACKUP_TABLES: BackupTableName[] = [
|
|||||||
'users',
|
'users',
|
||||||
'domain_settings',
|
'domain_settings',
|
||||||
'user_revisions',
|
'user_revisions',
|
||||||
|
'trusted_two_factor_device_tokens',
|
||||||
'webauthn_credentials',
|
'webauthn_credentials',
|
||||||
'folders',
|
'folders',
|
||||||
'ciphers',
|
'ciphers',
|
||||||
@@ -51,6 +53,7 @@ export interface BackupImportResultBody {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings: number;
|
domainSettings: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
trustedTwoFactorDeviceTokens: number;
|
||||||
webauthnCredentials: number;
|
webauthnCredentials: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
@@ -172,6 +175,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
|||||||
'DELETE FROM ciphers',
|
'DELETE FROM ciphers',
|
||||||
'DELETE FROM folders',
|
'DELETE FROM folders',
|
||||||
'DELETE FROM webauthn_credentials',
|
'DELETE FROM webauthn_credentials',
|
||||||
|
'DELETE FROM trusted_two_factor_device_tokens',
|
||||||
'DELETE FROM domain_settings',
|
'DELETE FROM domain_settings',
|
||||||
'DELETE FROM user_revisions',
|
'DELETE FROM user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
@@ -296,6 +300,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
})),
|
})),
|
||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
|
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
||||||
folders: cloneRows(payload.folders || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
@@ -634,6 +639,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('trusted_two_factor_device_tokens'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('trusted_two_factor_device_tokens'),
|
||||||
|
['token', 'user_id', 'device_identifier', 'expires_at'],
|
||||||
|
payload.trusted_two_factor_device_tokens || []
|
||||||
|
)
|
||||||
|
);
|
||||||
await runInsertBatch(
|
await runInsertBatch(
|
||||||
db,
|
db,
|
||||||
tableName('webauthn_credentials'),
|
tableName('webauthn_credentials'),
|
||||||
@@ -712,6 +727,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -735,6 +751,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -776,6 +793,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domainSettings: (db.domain_settings || []).length,
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -853,6 +871,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -876,6 +895,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -923,6 +943,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domainSettings: (db.domain_settings || []).length,
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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'))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
const [present, setPresent] = useState(props.open);
|
const [present, setPresent] = useState(props.open);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const maskPointerStartedRef = useRef(false);
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
const titleId = `${dialogId}-title`;
|
const titleId = `${dialogId}-title`;
|
||||||
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
return createPortal((
|
return createPortal((
|
||||||
<div
|
<div
|
||||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
maskPointerStartedRef.current = event.target === event.currentTarget;
|
||||||
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
|
||||||
props.onCancel();
|
props.onCancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,6 @@ export default function NetworkStatusBadge() {
|
|||||||
const Icon = status === 'online' ? Wifi : WifiOff;
|
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
|
|
||||||
const checkService = async () => {
|
const checkService = async () => {
|
||||||
@@ -31,10 +30,7 @@ export default function NetworkStatusBadge() {
|
|||||||
setCurrentNetworkStatus('offline');
|
setCurrentNetworkStatus('offline');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reachable = await probeNodeWardenService();
|
await probeNodeWardenService();
|
||||||
if (!cancelled) {
|
|
||||||
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleNextCheck = () => {
|
const scheduleNextCheck = () => {
|
||||||
@@ -62,7 +58,6 @@ export default function NetworkStatusBadge() {
|
|||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
window.removeEventListener('online', handleOnline);
|
window.removeEventListener('online', handleOnline);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
|||||||
linkedId: field.linkedId ?? null,
|
linkedId: field.linkedId ?? null,
|
||||||
})),
|
})),
|
||||||
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
|
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
|
||||||
password: valueOrFallback(entry.password),
|
password: valueOrFallback(entry.decPassword ?? entry.password),
|
||||||
lastUsedDate: valueOrFallback(entry.lastUsedDate),
|
lastUsedDate: valueOrFallback(entry.lastUsedDate),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
|||||||
import {
|
import {
|
||||||
attachNodeWardenEncryptedAttachmentPayload,
|
attachNodeWardenEncryptedAttachmentPayload,
|
||||||
buildAccountEncryptedBitwardenJsonString,
|
buildAccountEncryptedBitwardenJsonString,
|
||||||
|
buildBitwardenCsvString,
|
||||||
buildBitwardenZipBytes,
|
buildBitwardenZipBytes,
|
||||||
buildExportFileName,
|
buildExportFileName,
|
||||||
buildNodeWardenAttachmentRecords,
|
buildNodeWardenAttachmentRecords,
|
||||||
@@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
mimeType: 'application/json',
|
mimeType: 'application/json',
|
||||||
bytes: new TextEncoder().encode(await getPlainJson()),
|
bytes: new TextEncoder().encode(await getPlainJson()),
|
||||||
};
|
};
|
||||||
|
} else if (format === 'bitwarden_csv') {
|
||||||
|
result = {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'text/csv;charset=utf-8',
|
||||||
|
bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())),
|
||||||
|
};
|
||||||
} else if (format === 'bitwarden_encrypted_json') {
|
} else if (format === 'bitwarden_encrypted_json') {
|
||||||
if (request.encryptedJsonMode === 'password') {
|
if (request.encryptedJsonMode === 'password') {
|
||||||
const plainJson = await getPlainJson();
|
const plainJson = await getPlainJson();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
TokenSuccess,
|
TokenSuccess,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||||
|
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
|
||||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||||
@@ -474,6 +475,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(input, { ...init, headers });
|
const response = await fetch(input, { ...init, headers });
|
||||||
|
recordNodeWardenReachable();
|
||||||
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -484,6 +486,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (attempt === maxAttempts - 1) {
|
if (attempt === maxAttempts - 1) {
|
||||||
|
recordNodeWardenUnreachable();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings?: number;
|
domainSettings?: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
trustedTwoFactorDeviceTokens?: number;
|
||||||
webauthnCredentials?: number;
|
webauthnCredentials?: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
|
|||||||
@@ -279,20 +279,16 @@ export async function hydrateLockedSession(
|
|||||||
fallbackProfile: Profile | null = null
|
fallbackProfile: Profile | null = null
|
||||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||||
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
||||||
let serviceReachable = true;
|
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||||
if (hasOfflineUnlock) {
|
return {
|
||||||
serviceReachable = await probeNodeWardenService();
|
session,
|
||||||
if (!serviceReachable) {
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
return {
|
};
|
||||||
session,
|
|
||||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshedSession = await maybeRefreshSession(session);
|
const refreshedSession = await maybeRefreshSession(session);
|
||||||
if (!refreshedSession?.accessToken) {
|
if (!refreshedSession?.accessToken) {
|
||||||
if (hasOfflineUnlock && !serviceReachable) {
|
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
@@ -571,14 +567,8 @@ export async function performUnlock(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasOfflineUnlock) {
|
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||||
if (browserReportsOffline()) {
|
return unlockOffline();
|
||||||
return unlockOffline();
|
|
||||||
}
|
|
||||||
const serviceReachable = await probeNodeWardenService();
|
|
||||||
if (!serviceReachable) {
|
|
||||||
return unlockOffline();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
|
|||||||
|
|
||||||
export const EXPORT_FORMATS = [
|
export const EXPORT_FORMATS = [
|
||||||
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
||||||
|
{ id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' },
|
||||||
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
||||||
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
||||||
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
||||||
@@ -70,6 +71,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csvText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvCell(value: unknown): string {
|
||||||
|
const text = csvText(value);
|
||||||
|
if (!/[",\r\n]/.test(text)) return text;
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvString(rows: string[][]): string {
|
||||||
|
return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSingleRowCsvString(values: string[]): string {
|
||||||
|
return values.map(escapeCsvCell).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
function isCipherString(value: string): boolean {
|
function isCipherString(value: string): boolean {
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||||
}
|
}
|
||||||
@@ -383,6 +409,106 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
|
|||||||
return JSON.stringify(doc, null, 2);
|
return JSON.stringify(doc, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BITWARDEN_CSV_HEADERS = [
|
||||||
|
'folder',
|
||||||
|
'favorite',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'notes',
|
||||||
|
'fields',
|
||||||
|
'reprompt',
|
||||||
|
'login_uri',
|
||||||
|
'login_username',
|
||||||
|
'login_password',
|
||||||
|
'login_totp',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function bitwardenCsvType(type: number): 'login' | 'note' {
|
||||||
|
return type === 1 ? 'login' : 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTypeLabel(type: number): string {
|
||||||
|
if (type === 3) return 'card';
|
||||||
|
if (type === 4) return 'identity';
|
||||||
|
if (type === 5) return 'sshKey';
|
||||||
|
if (type === 2) return 'note';
|
||||||
|
return `type ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFieldLine(lines: string[], name: unknown, value: unknown): void {
|
||||||
|
const key = csvText(name).trim();
|
||||||
|
const text = csvText(value);
|
||||||
|
if (!key || !text) return;
|
||||||
|
lines.push(`${key}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
|
||||||
|
if (!isRecord(value)) return;
|
||||||
|
for (const [key, fieldValue] of Object.entries(value)) {
|
||||||
|
appendFieldLine(lines, `${prefix}.${key}`, fieldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const fields = Array.isArray(item.fields) ? item.fields : [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!isRecord(field)) continue;
|
||||||
|
appendFieldLine(lines, field.name, field.value);
|
||||||
|
}
|
||||||
|
if (type !== 1 && type !== 2) {
|
||||||
|
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
|
||||||
|
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFolderNameById(foldersRaw: unknown): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
const folders = Array.isArray(foldersRaw) ? foldersRaw : [];
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (!isRecord(folder)) continue;
|
||||||
|
const id = csvText(folder.id).trim();
|
||||||
|
if (!id) continue;
|
||||||
|
out.set(id, csvText(folder.name));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBitwardenCsvLoginUri(login: Record<string, unknown> | null): string {
|
||||||
|
const uris = Array.isArray(login?.uris) ? login.uris : [];
|
||||||
|
return buildSingleRowCsvString(uris
|
||||||
|
.map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : ''))
|
||||||
|
.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBitwardenCsvString(bitwardenJsonDoc: Record<string, unknown>): string {
|
||||||
|
const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders);
|
||||||
|
const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]];
|
||||||
|
const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : [];
|
||||||
|
for (const itemRaw of items) {
|
||||||
|
if (!isRecord(itemRaw)) continue;
|
||||||
|
const type = normalizeNumber(itemRaw.type, 1);
|
||||||
|
const isLogin = type === 1;
|
||||||
|
const login = isRecord(itemRaw.login) ? itemRaw.login : null;
|
||||||
|
const folderId = csvText(itemRaw.folderId).trim();
|
||||||
|
rows.push([
|
||||||
|
folderNameById.get(folderId) || '',
|
||||||
|
itemRaw.favorite ? '1' : '0',
|
||||||
|
bitwardenCsvType(type),
|
||||||
|
csvText(itemRaw.name) || '--',
|
||||||
|
csvText(itemRaw.notes),
|
||||||
|
buildBitwardenCsvFields(itemRaw, type),
|
||||||
|
String(normalizeNumber(itemRaw.reprompt, 0)),
|
||||||
|
isLogin ? buildBitwardenCsvLoginUri(login) : '',
|
||||||
|
isLogin ? csvText(login?.username) : '',
|
||||||
|
isLogin ? csvText(login?.password) : '',
|
||||||
|
isLogin ? csvText(login?.totp) : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return `\uFEFF${buildCsvString(rows)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||||
const userEnc = base64ToBytes(args.userEncB64);
|
const userEnc = base64ToBytes(args.userEncB64);
|
||||||
const userMac = base64ToBytes(args.userMacB64);
|
const userMac = base64ToBytes(args.userMacB64);
|
||||||
@@ -566,11 +692,13 @@ function nowStamp(now = new Date()): string {
|
|||||||
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
||||||
const stamp = nowStamp();
|
const stamp = nowStamp();
|
||||||
if (
|
if (
|
||||||
|
format === 'bitwarden_csv' ||
|
||||||
format === 'bitwarden_json' ||
|
format === 'bitwarden_json' ||
|
||||||
format === 'bitwarden_encrypted_json' ||
|
format === 'bitwarden_encrypted_json' ||
|
||||||
format === 'nodewarden_json' ||
|
format === 'nodewarden_json' ||
|
||||||
format === 'nodewarden_encrypted_json'
|
format === 'nodewarden_encrypted_json'
|
||||||
) {
|
) {
|
||||||
|
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
|
||||||
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
||||||
return `bitwarden_export_${stamp}.json`;
|
return `bitwarden_export_${stamp}.json`;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
export type NetworkStatus = 'online' | 'offline';
|
export type NetworkStatus = 'online' | 'offline';
|
||||||
|
|
||||||
const STATUS_PROBE_TIMEOUT_MS = 3500;
|
const STATUS_PROBE_TIMEOUT_MS = 8000;
|
||||||
const STATUS_PROBE_CACHE_MS = 5000;
|
const STATUS_PROBE_CACHE_MS = 5000;
|
||||||
|
const PROBE_FAILURES_BEFORE_OFFLINE = 2;
|
||||||
const listeners = new Set<(status: NetworkStatus) => void>();
|
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||||
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||||
let pendingProbe: Promise<boolean> | null = null;
|
let pendingProbe: Promise<boolean> | null = null;
|
||||||
let lastProbeAt = 0;
|
let lastProbeAt = 0;
|
||||||
let lastProbeResult = currentStatus === 'online';
|
let lastProbeResult = currentStatus === 'online';
|
||||||
|
let consecutiveProbeFailures = 0;
|
||||||
|
|
||||||
export function browserReportsOffline(): boolean {
|
export function browserReportsOffline(): boolean {
|
||||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||||
@@ -35,8 +37,23 @@ export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordNodeWardenReachable(): void {
|
||||||
|
consecutiveProbeFailures = 0;
|
||||||
|
lastProbeResult = true;
|
||||||
|
setCurrentNetworkStatus('online');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordNodeWardenUnreachable(): void {
|
||||||
|
lastProbeResult = false;
|
||||||
|
consecutiveProbeFailures += 1;
|
||||||
|
if (browserReportsOffline() || consecutiveProbeFailures >= PROBE_FAILURES_BEFORE_OFFLINE) {
|
||||||
|
setCurrentNetworkStatus('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function probeNodeWardenService(): Promise<boolean> {
|
export async function probeNodeWardenService(): Promise<boolean> {
|
||||||
if (browserReportsOffline()) {
|
if (browserReportsOffline()) {
|
||||||
|
consecutiveProbeFailures = PROBE_FAILURES_BEFORE_OFFLINE;
|
||||||
setCurrentNetworkStatus('offline');
|
setCurrentNetworkStatus('offline');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -68,8 +85,11 @@ export async function probeNodeWardenService(): Promise<boolean> {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
lastProbeAt = Date.now();
|
lastProbeAt = Date.now();
|
||||||
lastProbeResult = result;
|
if (result) {
|
||||||
setCurrentNetworkStatus(result ? 'online' : 'offline');
|
recordNodeWardenReachable();
|
||||||
|
} else {
|
||||||
|
recordNodeWardenUnreachable();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -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,5 +1,4 @@
|
|||||||
import type { Send } from './types';
|
import type { Send } from './types';
|
||||||
import { getCurrentNetworkStatus } from './network-status';
|
|
||||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||||
|
|
||||||
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
||||||
@@ -13,7 +12,6 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
|
|||||||
function getWorker(): Worker | null {
|
function getWorker(): Worker | null {
|
||||||
if (typeof Worker === 'undefined') return null;
|
if (typeof Worker === 'undefined') return null;
|
||||||
if (worker) return worker;
|
if (worker) return worker;
|
||||||
if (getCurrentNetworkStatus() === 'offline') return null;
|
|
||||||
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
||||||
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -809,6 +810,101 @@ h4 {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typography refinement: stronger scan targets for dense vault/admin surfaces. */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link,
|
||||||
|
.side-group-trigger,
|
||||||
|
.side-sub-link,
|
||||||
|
.tree-btn,
|
||||||
|
.mobile-settings-link,
|
||||||
|
.backup-destination-item,
|
||||||
|
.backup-browser-entry,
|
||||||
|
.sort-menu-item,
|
||||||
|
.create-menu-item,
|
||||||
|
.nav-layout-option {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link.active,
|
||||||
|
.side-group-trigger.active,
|
||||||
|
.side-sub-link.active,
|
||||||
|
.tree-btn.active,
|
||||||
|
.mobile-tab.active,
|
||||||
|
.mobile-settings-link.active,
|
||||||
|
.nav-layout-option.active {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title,
|
||||||
|
.list-count,
|
||||||
|
.field > span,
|
||||||
|
.table th,
|
||||||
|
.dialog-warning-kicker,
|
||||||
|
.backup-recommendation-group-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-sub,
|
||||||
|
.detail-sub,
|
||||||
|
.backup-destination-meta,
|
||||||
|
.totp-code-username,
|
||||||
|
.field-help,
|
||||||
|
.settings-field-note {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.input,
|
||||||
|
.search-input,
|
||||||
|
.user-chip,
|
||||||
|
.network-status-badge {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-danger,
|
||||||
|
.btn.full,
|
||||||
|
.topbar-actions .btn,
|
||||||
|
.network-status-badge {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h4,
|
||||||
|
.settings-module h3,
|
||||||
|
.section-head h3,
|
||||||
|
.section-head h4,
|
||||||
|
.detail-title,
|
||||||
|
.totp-code-name,
|
||||||
|
.backup-destination-name,
|
||||||
|
.mobile-sidebar-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-item,
|
.toast-item,
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ 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: var(--font-sans);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
letter-spacing: var(--tracking-normal);
|
||||||
|
font-feature-settings: 'liga' 1, 'kern' 1, 'calt' 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -16,7 +24,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 +33,28 @@ body.dialog-open {
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+75
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,66 +7,114 @@
|
|||||||
--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 Families */
|
||||||
|
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
|
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
|
||||||
|
'Noto Sans CJK SC', 'Noto Sans SC', Arial, sans-serif;
|
||||||
|
--font-mono: 'SFMono-Regular', 'Cascadia Code', 'Cascadia Mono', Consolas, 'Liberation Mono', monospace;
|
||||||
|
|
||||||
|
/* Typography Scale */
|
||||||
|
--font-xs: 11px;
|
||||||
|
--font-sm: 14px;
|
||||||
|
--font-base: 15px;
|
||||||
|
--font-md: 16px;
|
||||||
|
--font-lg: 17px;
|
||||||
|
--font-xl: 19px;
|
||||||
|
--font-2xl: 21px;
|
||||||
|
--font-3xl: 25px;
|
||||||
|
--font-4xl: 29px;
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
--tracking-tight: 0;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0;
|
||||||
|
--tracking-wider: 0;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user