Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2704fd41 | |||
| 7e0406f751 | |||
| d5c2ab2b0f | |||
| 9e0908f43c | |||
| 7b3be2c819 | |||
| a8183166ac | |||
| 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,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';
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
|||||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
export type BackupDestinationType = 's3' | 'webdav';
|
export type BackupDestinationType = 's3' | 'webdav';
|
||||||
|
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
|
||||||
|
|
||||||
export interface S3BackupDestination {
|
export interface S3BackupDestination {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
|
addressingStyle: S3BackupAddressingStyle;
|
||||||
region: string;
|
region: string;
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
secretAccessKey: string;
|
secretAccessKey: string;
|
||||||
@@ -103,6 +105,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
|||||||
return {
|
return {
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
bucket: '',
|
bucket: '',
|
||||||
|
addressingStyle: 'path-style',
|
||||||
region: BACKUP_DEFAULT_S3_REGION,
|
region: BACKUP_DEFAULT_S3_REGION,
|
||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type BackupRuntimeState,
|
type BackupRuntimeState,
|
||||||
type BackupScheduleConfig,
|
type BackupScheduleConfig,
|
||||||
type BackupSettings,
|
type BackupSettings,
|
||||||
|
type S3BackupAddressingStyle,
|
||||||
type S3BackupDestination,
|
type S3BackupDestination,
|
||||||
type WebDavBackupDestination,
|
type WebDavBackupDestination,
|
||||||
createBackupRandomId,
|
createBackupRandomId,
|
||||||
@@ -35,6 +36,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings,
|
BackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
@@ -109,6 +111,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
const source = isPlainObject(value) ? value : {};
|
const source = isPlainObject(value) ? value : {};
|
||||||
const endpoint = asTrimmedString(source.endpoint);
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
const bucket = asTrimmedString(source.bucket);
|
const bucket = asTrimmedString(source.bucket);
|
||||||
|
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
|
||||||
|
const addressingStyle: S3BackupAddressingStyle =
|
||||||
|
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
|
||||||
const accessKeyId = asTrimmedString(source.accessKeyId);
|
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||||
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||||
const region = asTrimmedString(source.region) || 'auto';
|
const region = asTrimmedString(source.region) || 'auto';
|
||||||
@@ -131,6 +136,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
return {
|
return {
|
||||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||||
bucket,
|
bucket,
|
||||||
|
addressingStyle,
|
||||||
region,
|
region,
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
secretAccessKey,
|
secretAccessKey,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -448,8 +448,27 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||||
|
const hostname = endpoint.hostname.toLowerCase();
|
||||||
|
const bucketName = bucket.trim().toLowerCase();
|
||||||
|
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
|
||||||
|
}
|
||||||
|
|
||||||
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
const endpoint = new URL(config.endpoint.replace(/\/+$/, ''));
|
||||||
|
const bucket = config.bucket.trim();
|
||||||
|
|
||||||
|
if (config.addressingStyle === 'virtual-hosted-style') {
|
||||||
|
if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint;
|
||||||
|
endpoint.hostname = `${bucket}.${endpoint.hostname}`;
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL {
|
||||||
|
return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||||
@@ -501,7 +520,7 @@ async function putToS3(
|
|||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -594,7 +613,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
|||||||
throw new Error('Please select a backup file');
|
throw new Error('Please select a backup file');
|
||||||
}
|
}
|
||||||
const objectKey = normalizeS3ObjectKey(config, normalized);
|
const objectKey = normalizeS3ObjectKey(config, normalized);
|
||||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'GET', url);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`S3 download failed: ${response.status}`);
|
throw new Error(`S3 download failed: ${response.status}`);
|
||||||
@@ -610,7 +629,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
|||||||
|
|
||||||
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'DELETE', url);
|
const response = await signedS3Request(config, 'DELETE', url);
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
throw new Error(`S3 delete failed: ${response.status}`);
|
throw new Error(`S3 delete failed: ${response.status}`);
|
||||||
@@ -619,7 +638,7 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
|
|||||||
|
|
||||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'HEAD', url);
|
const response = await signedS3Request(config, 'HEAD', url);
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return false;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 619 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Full-bleed background for any/maskable -->
|
||||||
|
<rect width="512" height="512" fill="#116FF9"/>
|
||||||
|
<!-- Logo scaled to ~50% centered in safe zone (inner 66% = Android adaptive icon guideline) -->
|
||||||
|
<g transform="translate(256,256) scale(0.5) translate(-380,-380)">
|
||||||
|
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
|
||||||
|
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -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);
|
||||||
@@ -1477,7 +1535,7 @@ export default function App() {
|
|||||||
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
|
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
|
||||||
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
|
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
|
||||||
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
||||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
const showSidebarToggle = mobileLayout && location === '/sends';
|
||||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||||
const demoDomainRules = useMemo<DomainRules>(() => ({
|
const demoDomainRules = useMemo<DomainRules>(() => ({
|
||||||
equivalentDomains: [
|
equivalentDomains: [
|
||||||
@@ -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,7 +338,8 @@ 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) => (
|
||||||
|
<Route key={path} path={path}>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{props.mobileLayout && (
|
{props.mobileLayout && (
|
||||||
<div className="mobile-settings-subhead">
|
<div className="mobile-settings-subhead">
|
||||||
@@ -342,7 +354,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
devices={props.authorizedDevices}
|
devices={props.authorizedDevices}
|
||||||
loading={props.authorizedDevicesLoading}
|
loading={props.authorizedDevicesLoading}
|
||||||
error={props.authorizedDevicesError}
|
error={props.authorizedDevicesError}
|
||||||
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
|
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
onTrustPermanently={props.onTrustDevicePermanently}
|
onTrustPermanently={props.onTrustDevicePermanently}
|
||||||
@@ -353,6 +370,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</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">
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export default function ToastHost({ toasts, onClose }: ToastHostProps) {
|
|||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<li key={toast.id} className={`toast-item ${toast.type}`}>
|
<li key={toast.id} className={`toast-item ${toast.type}`}>
|
||||||
<div className="toast-text">{toast.text}</div>
|
<div className="toast-text">{toast.text}</div>
|
||||||
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}>
|
<button type="button" className="toast-close" onClick={() => onClose(toast.id)} aria-label="关闭通知">
|
||||||
x
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="toast-progress" />
|
<div className="toast-progress" />
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
|
|||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import WebsiteIcon from '@/components/vault/WebsiteIcon';
|
import WebsiteIcon from '@/components/vault/WebsiteIcon';
|
||||||
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
|
import { formatTotp, isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
interface TotpCodesPageProps {
|
interface TotpCodesPageProps {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
@@ -26,13 +26,6 @@ function getTotpTimeState(): { windowId: number; remain: number } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTotp(code: string): string {
|
|
||||||
if (!code) return code;
|
|
||||||
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
|
||||||
if (code.length < 6) return code;
|
|
||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import {
|
|||||||
createEmptyDraft,
|
createEmptyDraft,
|
||||||
creationTimeValue,
|
creationTimeValue,
|
||||||
draftFromCipher,
|
draftFromCipher,
|
||||||
buildCipherDuplicateSignature,
|
buildCipherDuplicateSignatures,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
firstPasskeyCreationTime,
|
firstPasskeyCreationTime,
|
||||||
isCipherVisibleInArchive,
|
isCipherVisibleInArchive,
|
||||||
isCipherVisibleInNormalVault,
|
isCipherVisibleInNormalVault,
|
||||||
isCipherVisibleInTrash,
|
isCipherVisibleInTrash,
|
||||||
sortTimeValue,
|
sortTimeValue,
|
||||||
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||||
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
||||||
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
||||||
|
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
|
||||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
|
|
||||||
const duplicateSignatureInfo = useMemo(() => {
|
const duplicateSignatureInfo = useMemo(() => {
|
||||||
if (sidebarFilter.kind !== 'duplicates') return null;
|
if (sidebarFilter.kind !== 'duplicates') return null;
|
||||||
const byId = new Map<string, string>();
|
const byId = new Map<string, string[]>();
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const cipher of props.ciphers) {
|
for (const cipher of props.ciphers) {
|
||||||
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||||
const signature = buildCipherDuplicateSignature(cipher);
|
const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
|
||||||
byId.set(cipher.id, signature);
|
byId.set(cipher.id, signatures);
|
||||||
|
for (const signature of signatures) {
|
||||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return { byId, counts };
|
return { byId, counts };
|
||||||
}, [props.ciphers, sidebarFilter.kind]);
|
}, [props.ciphers, sidebarFilter.kind, duplicateMode]);
|
||||||
|
|
||||||
|
const duplicateGroupIndexById = useMemo(() => {
|
||||||
|
if (!duplicateSignatureInfo) return new Map<string, number>();
|
||||||
|
const groupKeyById = new Map<string, string>();
|
||||||
|
const groupKeys = new Set<string>();
|
||||||
|
for (const cipher of props.ciphers) {
|
||||||
|
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
|
||||||
|
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
|
||||||
|
.sort()[0];
|
||||||
|
if (!groupKey) continue;
|
||||||
|
groupKeyById.set(cipher.id, groupKey);
|
||||||
|
groupKeys.add(groupKey);
|
||||||
|
}
|
||||||
|
const groupIndexByKey = new Map<string, number>();
|
||||||
|
Array.from(groupKeys).sort().forEach((groupKey, index) => {
|
||||||
|
groupIndexByKey.set(groupKey, index % 64);
|
||||||
|
});
|
||||||
|
const byId = new Map<string, number>();
|
||||||
|
for (const [cipherId, groupKey] of groupKeyById.entries()) {
|
||||||
|
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
|
||||||
|
}
|
||||||
|
return byId;
|
||||||
|
}, [props.ciphers, duplicateSignatureInfo]);
|
||||||
|
|
||||||
const filteredCiphers = useMemo(() => {
|
const filteredCiphers = useMemo(() => {
|
||||||
const next = props.ciphers.filter((cipher) => {
|
const next = props.ciphers.filter((cipher) => {
|
||||||
@@ -358,9 +385,12 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (!isCipherVisibleInArchive(cipher)) return false;
|
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||||
} else {
|
} else {
|
||||||
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||||
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
|
if (sidebarFilter.kind === 'duplicates') {
|
||||||
|
const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
|
||||||
|
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
||||||
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
||||||
if (sidebarFilter.kind === 'folder') {
|
if (sidebarFilter.kind === 'folder') {
|
||||||
@@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const sidebarFilterKey = useMemo(() => {
|
const sidebarFilterKey = useMemo(() => {
|
||||||
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
||||||
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
||||||
|
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
|
||||||
return sidebarFilter.kind;
|
return sidebarFilter.kind;
|
||||||
}, [sidebarFilter]);
|
}, [sidebarFilter, duplicateMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setListScrollTop(0);
|
setListScrollTop(0);
|
||||||
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}, [sidebarFilter.kind, sortMode]);
|
}, [sidebarFilter.kind, sortMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||||
|
}, [sidebarFilter.kind, duplicateMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
if (!filteredCiphers.length) {
|
if (!filteredCiphers.length) {
|
||||||
@@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
||||||
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
||||||
const handleSelectDuplicates = useCallback(() => {
|
const handleSelectDuplicates = useCallback(() => {
|
||||||
|
if (duplicateMode !== 'exact') return;
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
|
const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
|
||||||
if (seen.has(signature)) {
|
if (seen.has(signature)) {
|
||||||
map[cipher.id] = true;
|
map[cipher.id] = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
seen.add(signature);
|
seen.add(signature);
|
||||||
}
|
}
|
||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}, [filteredCiphers, duplicateSignatureInfo]);
|
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
@@ -1079,13 +1115,16 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
error={props.error}
|
error={props.error}
|
||||||
|
folders={props.folders}
|
||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
sortMode={sortMode}
|
sortMode={sortMode}
|
||||||
sortMenuOpen={sortMenuOpen}
|
sortMenuOpen={sortMenuOpen}
|
||||||
|
duplicateMode={duplicateMode}
|
||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
totalCipherCount={totalCipherCount}
|
totalCipherCount={totalCipherCount}
|
||||||
filteredCiphers={filteredCiphers}
|
filteredCiphers={filteredCiphers}
|
||||||
visibleCiphers={visibleCiphers}
|
visibleCiphers={visibleCiphers}
|
||||||
|
duplicateGroupIndexById={duplicateGroupIndexById}
|
||||||
virtualRange={virtualRange}
|
virtualRange={virtualRange}
|
||||||
selectedCipherId={selectedCipherId}
|
selectedCipherId={selectedCipherId}
|
||||||
selectedMap={selectedMap}
|
selectedMap={selectedMap}
|
||||||
@@ -1102,6 +1141,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onSearchCompositionEnd={handleSearchCompositionEnd}
|
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||||
onToggleSortMenu={handleToggleSortMenu}
|
onToggleSortMenu={handleToggleSortMenu}
|
||||||
onSelectSortMode={handleSelectSortMode}
|
onSelectSortMode={handleSelectSortMode}
|
||||||
|
onDuplicateModeChange={setDuplicateMode}
|
||||||
|
onChangeFilter={setSidebarFilter}
|
||||||
onSyncVault={handleSyncVault}
|
onSyncVault={handleSyncVault}
|
||||||
onOpenBulkDelete={handleOpenBulkDelete}
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
onSelectDuplicates={handleSelectDuplicates}
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
|||||||
import type {
|
import type {
|
||||||
BackupDestinationRecord,
|
BackupDestinationRecord,
|
||||||
RemoteBackupBrowserResponse,
|
RemoteBackupBrowserResponse,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
@@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
|
|
||||||
{props.selectedDestination.type === 's3' ? (
|
{props.selectedDestination.type === 's3' ? (
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field">
|
||||||
<span>{t('txt_backup_s3_endpoint')}</span>
|
<span>{t('txt_backup_s3_endpoint')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_s3_addressing_style')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as S3BackupDestination).addressingStyle || 'path-style'}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as S3BackupDestination),
|
||||||
|
addressingStyle: (event.currentTarget as HTMLSelectElement).value as S3BackupAddressingStyle,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<option value="path-style">{t('txt_backup_s3_addressing_path_style')}</option>
|
||||||
|
<option value="virtual-hosted-style">{t('txt_backup_s3_addressing_virtual_hosted_style')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_s3_bucket')}</span>
|
<span>{t('txt_backup_s3_bucket')}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { ComponentChildren, RefObject } from 'preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowUpDown,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
|
CreditCard,
|
||||||
|
Folder as FolderIcon,
|
||||||
|
FolderInput,
|
||||||
|
FolderX,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
LayoutGrid,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
ShieldUser,
|
||||||
|
Star,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-preact';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher, Folder } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
CreateTypeIcon,
|
CreateTypeIcon,
|
||||||
getCreateTypeOptions,
|
getCreateTypeOptions,
|
||||||
|
getDuplicateDetectionOptions,
|
||||||
getVaultSortOptions,
|
getVaultSortOptions,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
@@ -25,13 +50,16 @@ interface VaultListPanelProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
folders: Folder[];
|
||||||
searchInput: string;
|
searchInput: string;
|
||||||
sortMode: VaultSortMode;
|
sortMode: VaultSortMode;
|
||||||
sortMenuOpen: boolean;
|
sortMenuOpen: boolean;
|
||||||
|
duplicateMode: DuplicateDetectionMode;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
totalCipherCount: number;
|
totalCipherCount: number;
|
||||||
filteredCiphers: Cipher[];
|
filteredCiphers: Cipher[];
|
||||||
visibleCiphers: Cipher[];
|
visibleCiphers: Cipher[];
|
||||||
|
duplicateGroupIndexById: Map<string, number>;
|
||||||
virtualRange: VirtualRange;
|
virtualRange: VirtualRange;
|
||||||
selectedCipherId: string;
|
selectedCipherId: string;
|
||||||
selectedMap: Record<string, boolean>;
|
selectedMap: Record<string, boolean>;
|
||||||
@@ -48,6 +76,8 @@ interface VaultListPanelProps {
|
|||||||
onSearchCompositionEnd: (value: string) => void;
|
onSearchCompositionEnd: (value: string) => void;
|
||||||
onToggleSortMenu: () => void;
|
onToggleSortMenu: () => void;
|
||||||
onSelectSortMode: (value: VaultSortMode) => void;
|
onSelectSortMode: (value: VaultSortMode) => void;
|
||||||
|
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
|
||||||
|
onChangeFilter: (filter: SidebarFilter) => void;
|
||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
onSelectDuplicates: () => void;
|
onSelectDuplicates: () => void;
|
||||||
@@ -69,15 +99,28 @@ interface CipherListItemProps {
|
|||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
|
duplicateGroupIndex: number | null;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||||
onSelectCipher: (cipherId: string) => void;
|
onSelectCipher: (cipherId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MobileFilterMenuKey = 'duplicate' | 'menu' | 'type' | 'folder';
|
||||||
|
|
||||||
|
interface MobileFilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentChildren;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||||
|
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`list-item ${props.selected ? 'active' : ''}`}
|
className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
|
||||||
|
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target.closest('.row-check')) return;
|
if (target.closest('.row-check')) return;
|
||||||
@@ -107,13 +150,116 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState<MobileFilterMenuKey | null>(null);
|
||||||
|
const mobileFilterRef = useRef<HTMLDivElement | null>(null);
|
||||||
const createTypeOptions = getCreateTypeOptions();
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
|
const duplicateDetectionOptions = getDuplicateDetectionOptions();
|
||||||
const vaultSortOptions = getVaultSortOptions();
|
const vaultSortOptions = getVaultSortOptions();
|
||||||
const createMenu = (
|
const duplicateModeOptions: MobileFilterOption[] = duplicateDetectionOptions.map((option) => ({
|
||||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
icon: option.value === 'login-site' ? <Globe size={14} /> : option.value === 'exact' ? <Copy size={14} /> : <KeyRound size={14} />,
|
||||||
|
active: props.duplicateMode === option.value,
|
||||||
|
onSelect: () => props.onDuplicateModeChange(option.value),
|
||||||
|
}));
|
||||||
|
const menuFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: 'all', label: t('txt_all_items'), icon: <LayoutGrid size={14} />, active: props.sidebarFilter.kind === 'all', onSelect: () => props.onChangeFilter({ kind: 'all' }) },
|
||||||
|
{ value: 'favorite', label: t('txt_favorites'), icon: <Star size={14} />, active: props.sidebarFilter.kind === 'favorite', onSelect: () => props.onChangeFilter({ kind: 'favorite' }) },
|
||||||
|
{ value: 'archive', label: t('txt_archive'), icon: <Archive size={14} />, active: props.sidebarFilter.kind === 'archive', onSelect: () => props.onChangeFilter({ kind: 'archive' }) },
|
||||||
|
{ value: 'trash', label: t('txt_trash'), icon: <Trash2 size={14} />, active: props.sidebarFilter.kind === 'trash', onSelect: () => props.onChangeFilter({ kind: 'trash' }) },
|
||||||
|
{ value: 'duplicates', label: t('txt_duplicates'), icon: <Copy size={14} />, active: props.sidebarFilter.kind === 'duplicates', onSelect: () => props.onChangeFilter({ kind: 'duplicates' }) },
|
||||||
|
];
|
||||||
|
const typeMobileFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: 'login', label: t('txt_login'), icon: <Globe size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'login' }) },
|
||||||
|
{ value: 'card', label: t('txt_card'), icon: <CreditCard size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'card' }) },
|
||||||
|
{ value: 'identity', label: t('txt_identity'), icon: <ShieldUser size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'identity' }) },
|
||||||
|
{ value: 'note', label: t('txt_note'), icon: <StickyNote size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'note' }) },
|
||||||
|
{ value: 'ssh', label: t('txt_ssh_key'), icon: <KeyRound size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'ssh' }) },
|
||||||
|
];
|
||||||
|
const folderMobileFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: '__none__', label: t('txt_no_folder'), icon: <FolderX size={14} />, active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null, onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: null }) },
|
||||||
|
...props.folders.map((folder) => ({
|
||||||
|
value: folder.id,
|
||||||
|
label: folder.decName || folder.name || folder.id,
|
||||||
|
icon: <FolderIcon size={14} />,
|
||||||
|
active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id,
|
||||||
|
onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: folder.id }),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const menuFilterSelected = menuFilterOptions.find((option) => option.active);
|
||||||
|
const typeFilterSelected = typeMobileFilterOptions.find((option) => option.active);
|
||||||
|
const folderFilterSelected = folderMobileFilterOptions.find((option) => option.active);
|
||||||
|
const duplicateModeSelected = duplicateModeOptions.find((option) => option.active);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: Event) => {
|
||||||
|
if (!mobileFilterOpen) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (mobileFilterRef.current && target && !mobileFilterRef.current.contains(target)) {
|
||||||
|
setMobileFilterOpen(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setMobileFilterOpen(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [mobileFilterOpen]);
|
||||||
|
|
||||||
|
const renderMobileFilterMenu = (
|
||||||
|
key: MobileFilterMenuKey,
|
||||||
|
label: string,
|
||||||
|
selected: MobileFilterOption | undefined,
|
||||||
|
fallbackIcon: ComponentChildren,
|
||||||
|
options: MobileFilterOption[]
|
||||||
|
) => (
|
||||||
|
<div className="mobile-vault-filter-control">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary small mobile-fab-trigger"
|
className={`mobile-vault-filter-trigger ${mobileFilterOpen === key ? 'active' : ''}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={mobileFilterOpen === key}
|
||||||
|
onClick={() => setMobileFilterOpen((open) => open === key ? null : key)}
|
||||||
|
>
|
||||||
|
<span className="mobile-vault-filter-trigger-icon">{selected?.icon || fallbackIcon}</span>
|
||||||
|
<span className="mobile-vault-filter-trigger-label">{selected?.label || label}</span>
|
||||||
|
<ChevronDown size={13} className="mobile-vault-filter-chevron" />
|
||||||
|
</button>
|
||||||
|
{mobileFilterOpen === key && (
|
||||||
|
<div className="sort-menu mobile-vault-filter-menu" role="menu">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`sort-menu-item mobile-vault-filter-item ${option.active ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
option.onSelect();
|
||||||
|
setMobileFilterOpen(null);
|
||||||
|
}}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={option.active}
|
||||||
|
>
|
||||||
|
<span className="mobile-vault-filter-item-main">
|
||||||
|
<span className="mobile-vault-filter-item-icon">{option.icon}</span>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</span>
|
||||||
|
{option.active ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMenu = (
|
||||||
|
<div className={`create-menu-wrap ${props.isMobileLayout ? 'mobile-fab-wrap' : 'desktop-create-menu-wrap'}`} ref={props.createMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-primary small ${props.isMobileLayout ? 'mobile-fab-trigger' : 'desktop-create-trigger'}`}
|
||||||
aria-label={t('txt_add')}
|
aria-label={t('txt_add')}
|
||||||
title={t('txt_add')}
|
title={t('txt_add')}
|
||||||
onClick={props.onToggleCreateMenu}
|
onClick={props.onToggleCreateMenu}
|
||||||
@@ -135,11 +281,54 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="list-head">
|
<div className="list-toolbar-stack" ref={mobileFilterRef}>
|
||||||
|
<div className={`list-head ${props.selectedCount > 0 ? 'selection-mode' : ''}`}>
|
||||||
|
{props.selectedCount > 0 ? (
|
||||||
|
<>
|
||||||
|
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||||
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||||
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
{props.sidebarFilter.kind === 'trash' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind === 'archive' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||||
|
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||||
|
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={props.busy} onClick={props.onOpenBulkDelete}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="search-input-wrap">
|
<div className="search-input-wrap">
|
||||||
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||||
|
<div className="duplicate-mode-head-menu">
|
||||||
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<input
|
<input
|
||||||
className="search-input"
|
className="search-input"
|
||||||
placeholder={t('txt_search_your_secure_vault')}
|
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||||
value={props.searchInput}
|
value={props.searchInput}
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
onCompositionStart={props.onSearchCompositionStart}
|
||||||
@@ -161,16 +350,23 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||||
|
<div className="duplicate-mode-head-menu">
|
||||||
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
|
className={`btn btn-secondary small sort-trigger sort-trigger-labeled ${props.sortMenuOpen ? 'active' : ''}`}
|
||||||
aria-label={t('txt_sort')}
|
aria-label={t('txt_sort')}
|
||||||
title={t('txt_sort')}
|
title={t('txt_sort')}
|
||||||
onClick={props.onToggleSortMenu}
|
onClick={props.onToggleSortMenu}
|
||||||
>
|
>
|
||||||
<ArrowUpDown size={14} className="btn-icon" />
|
<ArrowUpDown size={14} className="btn-icon" /> <span>{t('txt_sort')}</span>
|
||||||
</button>
|
</button>
|
||||||
{props.sortMenuOpen && (
|
{props.sortMenuOpen && (
|
||||||
<div className="sort-menu">
|
<div className="sort-menu">
|
||||||
@@ -188,55 +384,24 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
|
|
||||||
{t('txt_total_items_count', { count: props.totalCipherCount })}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
</button>
|
</button>
|
||||||
|
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar actions">
|
{props.isMobileLayout && (
|
||||||
{props.sidebarFilter.kind === 'duplicates' && (
|
<div className="mobile-vault-filter-row" aria-label={t('txt_filter')}>
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
{renderMobileFilterMenu('menu', t('txt_menu'), menuFilterSelected, <LayoutGrid size={14} />, menuFilterOptions)}
|
||||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
{renderMobileFilterMenu('type', t('txt_type'), typeFilterSelected, <Globe size={14} />, typeMobileFilterOptions)}
|
||||||
</button>
|
{renderMobileFilterMenu('folder', t('txt_folder'), folderFilterSelected, <FolderIcon size={14} />, folderMobileFilterOptions)}
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
|
||||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
|
||||||
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
|
||||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && (
|
|
||||||
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
|
||||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
|
||||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
|
||||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
|
||||||
</button>
|
|
||||||
{props.isMobileLayout && typeof document !== 'undefined'
|
|
||||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
|
||||||
: createMenu}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!props.selectedCount && props.isMobileLayout && props.sidebarFilter.kind !== 'duplicates' && typeof document !== 'undefined' && props.mobileFabVisible
|
||||||
|
? createPortal(createMenu, document.body)
|
||||||
|
: null}
|
||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
||||||
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
||||||
@@ -255,6 +420,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
selected={props.selectedCipherId === cipher.id}
|
selected={props.selectedCipherId === cipher.id}
|
||||||
checked={!!props.selectedMap[cipher.id]}
|
checked={!!props.selectedMap[cipher.id]}
|
||||||
|
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
|
||||||
subtitle={props.listSubtitle(cipher)}
|
subtitle={props.listSubtitle(cipher)}
|
||||||
onToggleSelected={props.onToggleSelected}
|
onToggleSelected={props.onToggleSelected}
|
||||||
onSelectCipher={props.onSelectCipher}
|
onSelectCipher={props.onSelectCipher}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
||||||
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
||||||
import WebsiteIcon from './WebsiteIcon';
|
import WebsiteIcon from './WebsiteIcon';
|
||||||
|
|
||||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
|
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
|
||||||
export type SidebarFilter =
|
export type SidebarFilter =
|
||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { kind: 'favorite' }
|
||||||
@@ -126,6 +129,16 @@ export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
|
|||||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||||
export const VAULT_LIST_ROW_HEIGHT = 74;
|
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||||
export const VAULT_LIST_OVERSCAN = 10;
|
export const VAULT_LIST_OVERSCAN = 10;
|
||||||
|
|
||||||
|
export function getDuplicateDetectionOptions(): Array<{ value: DuplicateDetectionMode; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: 'exact', label: t('txt_duplicate_mode_exact') },
|
||||||
|
{ value: 'login-site', label: t('txt_duplicate_mode_login_site') },
|
||||||
|
{ value: 'login-credentials', label: t('txt_duplicate_mode_login_credentials') },
|
||||||
|
{ value: 'password', label: t('txt_duplicate_mode_password') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||||
return [
|
return [
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
@@ -242,7 +255,7 @@ export function toBooleanFieldValue(raw: string): boolean {
|
|||||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||||
}
|
}
|
||||||
|
|
||||||
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
export { firstCipherUri, hostFromUri, websiteIconUrl };
|
||||||
|
|
||||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||||
return { uri: '', match: null, originalUri: '', extra: {} };
|
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||||
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
|
|||||||
return String(value || '');
|
return String(value || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateLoginUsername(cipher: Cipher): string {
|
||||||
|
return valueOrFallback(cipher.login?.decUsername ?? cipher.login?.username).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateLoginPassword(cipher: Cipher): string {
|
||||||
|
return valueOrFallback(cipher.login?.decPassword ?? cipher.login?.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateLoginSites(cipher: Cipher): string[] {
|
||||||
|
const sites = new Set<string>();
|
||||||
|
for (const uri of cipher.login?.uris || []) {
|
||||||
|
const raw = valueOrFallback(uri.decUri ?? uri.uri).trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
const host = hostFromUri(raw).trim().toLowerCase().replace(/^www\./, '');
|
||||||
|
const site = normalizeEquivalentDomain(raw) || host;
|
||||||
|
if (site) sites.add(site);
|
||||||
|
}
|
||||||
|
return Array.from(sites).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateSignature(parts: string[]): string {
|
||||||
|
return JSON.stringify(parts);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
type: Number(cipher.type || 1),
|
type: Number(cipher.type || 1),
|
||||||
@@ -326,13 +363,30 @@ 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),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
return JSON.stringify(normalized);
|
return JSON.stringify(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildCipherDuplicateSignatures(cipher: Cipher, mode: DuplicateDetectionMode): string[] {
|
||||||
|
if (mode === 'exact') return [buildCipherDuplicateSignature(cipher)];
|
||||||
|
if (Number(cipher.type || 1) !== 1 || !cipher.login) return [];
|
||||||
|
|
||||||
|
const username = duplicateLoginUsername(cipher);
|
||||||
|
const password = duplicateLoginPassword(cipher);
|
||||||
|
if (mode === 'password') {
|
||||||
|
return password ? [duplicateSignature(['password', password])] : [];
|
||||||
|
}
|
||||||
|
if (!username || !password) return [];
|
||||||
|
if (mode === 'login-credentials') {
|
||||||
|
return [duplicateSignature(['login-credentials', username, password])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicateLoginSites(cipher).map((site) => duplicateSignature(['login-site', site, username, password]));
|
||||||
|
}
|
||||||
|
|
||||||
export function createEmptyDraft(type: number): VaultDraft {
|
export function createEmptyDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
@@ -453,8 +507,9 @@ export function maskSecret(value: string): string {
|
|||||||
export function formatTotp(code: string): string {
|
export function formatTotp(code: string): string {
|
||||||
if (!code) return code;
|
if (!code) return code;
|
||||||
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||||
if (code.length < 6) return code;
|
if (code.length <= 4) return code;
|
||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
if (code.length === 8) return `${code.slice(0, 4)} ${code.slice(4)}`;
|
||||||
|
return code.replace(/(.{3})(?=.)/g, '$1 ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHistoryTime(value: string | null | undefined): string {
|
export function formatHistoryTime(value: string | null | undefined): string {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings as AdminBackupSettings,
|
BackupSettings as AdminBackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@shared/backup-schema';
|
} from '@shared/backup-schema';
|
||||||
@@ -26,6 +27,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
AdminBackupSettings,
|
AdminBackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
};
|
};
|
||||||
@@ -96,6 +98,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) {
|
|
||||||
serviceReachable = await probeNodeWardenService();
|
|
||||||
if (!serviceReachable) {
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
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,15 +567,9 @@ 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 };
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -220,6 +220,25 @@ function normalizeTotpSecret(secret: string): string {
|
|||||||
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOtpAuthParam(raw: string, name: string): string {
|
||||||
|
const queryStart = raw.indexOf('?');
|
||||||
|
if (queryStart < 0) return '';
|
||||||
|
const fragmentStart = raw.indexOf('#', queryStart + 1);
|
||||||
|
const query = raw.slice(queryStart + 1, fragmentStart > queryStart ? fragmentStart : undefined);
|
||||||
|
for (const part of query.split('&')) {
|
||||||
|
const eq = part.indexOf('=');
|
||||||
|
const key = eq >= 0 ? part.slice(0, eq) : part;
|
||||||
|
if (key.trim().toLowerCase() !== name.toLowerCase()) continue;
|
||||||
|
const value = eq >= 0 ? part.slice(eq + 1) : '';
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value.replace(/\+/g, ' '));
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function parseSteamSecret(raw: string): string {
|
function parseSteamSecret(raw: string): string {
|
||||||
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
|
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
|
||||||
if (!match?.[1]) return '';
|
if (!match?.[1]) return '';
|
||||||
@@ -276,7 +295,8 @@ function parseTotpConfig(raw: string): TotpConfig {
|
|||||||
if (/^otpauth:\/\//i.test(s)) {
|
if (/^otpauth:\/\//i.test(s)) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
const u = new URL(s);
|
||||||
if (u.hostname.toLowerCase() !== 'totp') {
|
const otpType = u.hostname.toLowerCase();
|
||||||
|
if (otpType !== 'totp') {
|
||||||
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
}
|
}
|
||||||
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
||||||
@@ -291,7 +311,16 @@ function parseTotpConfig(raw: string): TotpConfig {
|
|||||||
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
|
||||||
|
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
|
||||||
|
const steam = issuer === 'steam' || algorithm === 'steam';
|
||||||
|
return {
|
||||||
|
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
||||||
|
steam,
|
||||||
|
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
|
||||||
|
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
||||||
|
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
|
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV Password",
|
"txt_backup_webdav_password": "WebDAV Password",
|
||||||
"txt_backup_webdav_path": "Remote Folder",
|
"txt_backup_webdav_path": "Remote Folder",
|
||||||
"txt_backup_s3_endpoint": "S3 Endpoint",
|
"txt_backup_s3_endpoint": "S3 Endpoint",
|
||||||
|
"txt_backup_s3_addressing_style": "S3 Addressing Style",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style (default)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "Bucket",
|
"txt_backup_s3_bucket": "Bucket",
|
||||||
"txt_backup_s3_region": "Region",
|
"txt_backup_s3_region": "Region",
|
||||||
"txt_backup_s3_access_key": "Access Key",
|
"txt_backup_s3_access_key": "Access Key",
|
||||||
@@ -447,6 +450,11 @@ const en: Record<string, string> = {
|
|||||||
"txt_favorite": "Favorite",
|
"txt_favorite": "Favorite",
|
||||||
"txt_favorites": "Favorites",
|
"txt_favorites": "Favorites",
|
||||||
"txt_duplicates": "Duplicates",
|
"txt_duplicates": "Duplicates",
|
||||||
|
"txt_duplicate_detection_mode": "Match by",
|
||||||
|
"txt_duplicate_mode_exact": "Exact item",
|
||||||
|
"txt_duplicate_mode_login_site": "Site + username + password",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Username + password",
|
||||||
|
"txt_duplicate_mode_password": "Reused password",
|
||||||
"txt_field": "Field",
|
"txt_field": "Field",
|
||||||
"txt_field_label": "Field Label",
|
"txt_field_label": "Field Label",
|
||||||
"txt_field_label_is_required": "Field label is required.",
|
"txt_field_label_is_required": "Field label is required.",
|
||||||
@@ -750,6 +758,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_search_sends": "Search sends...",
|
"txt_search_sends": "Search sends...",
|
||||||
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
||||||
"txt_search_your_secure_vault": "Search your secure vault...",
|
"txt_search_your_secure_vault": "Search your secure vault...",
|
||||||
|
"txt_search_items_count": "Search within {count} items...",
|
||||||
"txt_clear_search": "Clear search",
|
"txt_clear_search": "Clear search",
|
||||||
"txt_clear_search_esc": "Clear search (Esc)",
|
"txt_clear_search_esc": "Clear search (Esc)",
|
||||||
"txt_sort": "Sort",
|
"txt_sort": "Sort",
|
||||||
@@ -1172,7 +1181,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;
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "Contraseña WebDAV",
|
"txt_backup_webdav_password": "Contraseña WebDAV",
|
||||||
"txt_backup_webdav_path": "Carpeta remota",
|
"txt_backup_webdav_path": "Carpeta remota",
|
||||||
"txt_backup_s3_endpoint": "Endpoint S3",
|
"txt_backup_s3_endpoint": "Endpoint S3",
|
||||||
|
"txt_backup_s3_addressing_style": "Estilo de direccionamiento S3",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style (predeterminado)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "Bucket S3",
|
"txt_backup_s3_bucket": "Bucket S3",
|
||||||
"txt_backup_s3_region": "Región",
|
"txt_backup_s3_region": "Región",
|
||||||
"txt_backup_s3_access_key": "Clave de acceso",
|
"txt_backup_s3_access_key": "Clave de acceso",
|
||||||
@@ -447,6 +450,11 @@ const es: Record<string, string> = {
|
|||||||
"txt_favorite": "Favorito",
|
"txt_favorite": "Favorito",
|
||||||
"txt_favorites": "Favoritos",
|
"txt_favorites": "Favoritos",
|
||||||
"txt_duplicates": "Duplicados",
|
"txt_duplicates": "Duplicados",
|
||||||
|
"txt_duplicate_detection_mode": "Coincidir por",
|
||||||
|
"txt_duplicate_mode_exact": "Elemento exacto",
|
||||||
|
"txt_duplicate_mode_login_site": "Sitio + usuario + contraseña",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Usuario + contraseña",
|
||||||
|
"txt_duplicate_mode_password": "Contraseña reutilizada",
|
||||||
"txt_field": "Campo",
|
"txt_field": "Campo",
|
||||||
"txt_field_label": "Etiqueta del campo",
|
"txt_field_label": "Etiqueta del campo",
|
||||||
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
|
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
|
||||||
@@ -750,6 +758,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_search_sends": "Buscar envíos...",
|
"txt_search_sends": "Buscar envíos...",
|
||||||
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
||||||
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
||||||
|
"txt_search_items_count": "Buscar entre {count} elementos...",
|
||||||
"txt_clear_search": "Limpiar búsqueda",
|
"txt_clear_search": "Limpiar búsqueda",
|
||||||
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
||||||
"txt_sort": "Ordenar",
|
"txt_sort": "Ordenar",
|
||||||
@@ -1172,7 +1181,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;
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "Пароль WebDAV",
|
"txt_backup_webdav_password": "Пароль WebDAV",
|
||||||
"txt_backup_webdav_path": "Удаленная папка",
|
"txt_backup_webdav_path": "Удаленная папка",
|
||||||
"txt_backup_s3_endpoint": "S3 endpoint",
|
"txt_backup_s3_endpoint": "S3 endpoint",
|
||||||
|
"txt_backup_s3_addressing_style": "Стиль адресации S3",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style (по умолчанию)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "Бакет",
|
"txt_backup_s3_bucket": "Бакет",
|
||||||
"txt_backup_s3_region": "Регион",
|
"txt_backup_s3_region": "Регион",
|
||||||
"txt_backup_s3_access_key": "Ключ доступа",
|
"txt_backup_s3_access_key": "Ключ доступа",
|
||||||
@@ -447,6 +450,11 @@ const ru: Record<string, string> = {
|
|||||||
"txt_favorite": "Любимый",
|
"txt_favorite": "Любимый",
|
||||||
"txt_favorites": "Избранное",
|
"txt_favorites": "Избранное",
|
||||||
"txt_duplicates": "Дубликаты",
|
"txt_duplicates": "Дубликаты",
|
||||||
|
"txt_duplicate_detection_mode": "Сравнивать по",
|
||||||
|
"txt_duplicate_mode_exact": "Полное совпадение",
|
||||||
|
"txt_duplicate_mode_login_site": "Сайт + логин + пароль",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Логин + пароль",
|
||||||
|
"txt_duplicate_mode_password": "Повтор пароля",
|
||||||
"txt_field": "Поле",
|
"txt_field": "Поле",
|
||||||
"txt_field_label": "Метка поля",
|
"txt_field_label": "Метка поля",
|
||||||
"txt_field_label_is_required": "Метка поля обязательна.",
|
"txt_field_label_is_required": "Метка поля обязательна.",
|
||||||
@@ -750,6 +758,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_search_sends": "Поиск отправляет...",
|
"txt_search_sends": "Поиск отправляет...",
|
||||||
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
||||||
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
||||||
|
"txt_search_items_count": "Поиск по {count} элементам...",
|
||||||
"txt_clear_search": "Очистить поиск",
|
"txt_clear_search": "Очистить поиск",
|
||||||
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
||||||
"txt_sort": "Сортировать",
|
"txt_sort": "Сортировать",
|
||||||
@@ -1172,7 +1181,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;
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV 密码",
|
"txt_backup_webdav_password": "WebDAV 密码",
|
||||||
"txt_backup_webdav_path": "远程目录",
|
"txt_backup_webdav_path": "远程目录",
|
||||||
"txt_backup_s3_endpoint": "S3 端点",
|
"txt_backup_s3_endpoint": "S3 端点",
|
||||||
|
"txt_backup_s3_addressing_style": "S3 寻址方式",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "存储桶",
|
"txt_backup_s3_bucket": "存储桶",
|
||||||
"txt_backup_s3_region": "区域",
|
"txt_backup_s3_region": "区域",
|
||||||
"txt_backup_s3_access_key": "访问密钥",
|
"txt_backup_s3_access_key": "访问密钥",
|
||||||
@@ -447,6 +450,11 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重复项",
|
"txt_duplicates": "重复项",
|
||||||
|
"txt_duplicate_detection_mode": "匹配方式",
|
||||||
|
"txt_duplicate_mode_exact": "完全相同",
|
||||||
|
"txt_duplicate_mode_login_site": "网站+账号+密码",
|
||||||
|
"txt_duplicate_mode_login_credentials": "账号+密码",
|
||||||
|
"txt_duplicate_mode_password": "密码复用",
|
||||||
"txt_field": "字段",
|
"txt_field": "字段",
|
||||||
"txt_field_label": "字段标签",
|
"txt_field_label": "字段标签",
|
||||||
"txt_field_label_is_required": "字段标签不能为空",
|
"txt_field_label_is_required": "字段标签不能为空",
|
||||||
@@ -750,6 +758,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_search_sends": "搜索 Send...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
||||||
"txt_search_your_secure_vault": "搜索你的密码库...",
|
"txt_search_your_secure_vault": "搜索你的密码库...",
|
||||||
|
"txt_search_items_count": "共 {count} 项中搜索...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
"txt_sort": "排序",
|
"txt_sort": "排序",
|
||||||
@@ -1172,7 +1181,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;
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV 密碼",
|
"txt_backup_webdav_password": "WebDAV 密碼",
|
||||||
"txt_backup_webdav_path": "遠程目錄",
|
"txt_backup_webdav_path": "遠程目錄",
|
||||||
"txt_backup_s3_endpoint": "S3 端點",
|
"txt_backup_s3_endpoint": "S3 端點",
|
||||||
|
"txt_backup_s3_addressing_style": "S3 定址方式",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style(預設)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "儲存桶",
|
"txt_backup_s3_bucket": "儲存桶",
|
||||||
"txt_backup_s3_region": "區域",
|
"txt_backup_s3_region": "區域",
|
||||||
"txt_backup_s3_access_key": "存取金鑰",
|
"txt_backup_s3_access_key": "存取金鑰",
|
||||||
@@ -447,6 +450,11 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重複項",
|
"txt_duplicates": "重複項",
|
||||||
|
"txt_duplicate_detection_mode": "匹配方式",
|
||||||
|
"txt_duplicate_mode_exact": "完全相同",
|
||||||
|
"txt_duplicate_mode_login_site": "網站+帳號+密碼",
|
||||||
|
"txt_duplicate_mode_login_credentials": "帳號+密碼",
|
||||||
|
"txt_duplicate_mode_password": "密碼重複使用",
|
||||||
"txt_field": "字段",
|
"txt_field": "字段",
|
||||||
"txt_field_label": "字段標籤",
|
"txt_field_label": "字段標籤",
|
||||||
"txt_field_label_is_required": "字段標籤不能為空",
|
"txt_field_label_is_required": "字段標籤不能為空",
|
||||||
@@ -750,6 +758,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_search_sends": "搜索 Send...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
||||||
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
||||||
|
"txt_search_items_count": "在共 {count} 項中搜索...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
"txt_sort": "排序",
|
"txt_sort": "排序",
|
||||||
@@ -1172,7 +1181,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';
|
||||||
|
|
||||||
@@ -595,7 +596,7 @@ h4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-count {
|
.list-count {
|
||||||
@@ -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);
|
||||||
@@ -973,6 +1069,7 @@ h4 {
|
|||||||
.list-panel {
|
.list-panel {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item {
|
.list-item {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
:root[data-theme='dark'] .textarea,
|
:root[data-theme='dark'] .textarea,
|
||||||
:root[data-theme='dark'] select.input,
|
:root[data-theme='dark'] select.input,
|
||||||
:root[data-theme='dark'] .search-input,
|
:root[data-theme='dark'] .search-input,
|
||||||
|
:root[data-theme='dark'] .mobile-vault-filter-trigger,
|
||||||
:root[data-theme='dark'] .dialog input,
|
:root[data-theme='dark'] .dialog input,
|
||||||
:root[data-theme='dark'] .dialog textarea,
|
:root[data-theme='dark'] .dialog textarea,
|
||||||
:root[data-theme='dark'] .dialog select {
|
:root[data-theme='dark'] .dialog select {
|
||||||
@@ -79,6 +80,13 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .mobile-vault-filter-trigger:hover,
|
||||||
|
:root[data-theme='dark'] .mobile-vault-filter-trigger.active {
|
||||||
|
background: var(--panel-muted);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--line));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .input::placeholder,
|
:root[data-theme='dark'] .input::placeholder,
|
||||||
:root[data-theme='dark'] .textarea::placeholder,
|
:root[data-theme='dark'] .textarea::placeholder,
|
||||||
:root[data-theme='dark'] input::placeholder,
|
:root[data-theme='dark'] input::placeholder,
|
||||||
@@ -200,6 +208,21 @@
|
|||||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.duplicate-group-item {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 34% 18%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 28% 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.duplicate-group-item:hover {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 36% 21%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 34% 38%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.duplicate-group-item.active {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 38% 24%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 42% 48%);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .card-brand-icon {
|
:root[data-theme='dark'] .card-brand-icon {
|
||||||
color: #bfdbfe;
|
color: #bfdbfe;
|
||||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
.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 {
|
||||||
@apply py-0 pr-[42px];
|
@apply py-0 pr-3.5;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
appearance: none;
|
appearance: auto;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: auto;
|
||||||
-moz-appearance: none;
|
-moz-appearance: auto;
|
||||||
background-image:
|
background-image: none;
|
||||||
linear-gradient(45deg, transparent 50%, #365fa8 50%),
|
|
||||||
linear-gradient(135deg, #365fa8 50%, transparent 50%);
|
|
||||||
background-position:
|
|
||||||
calc(100% - 18px) calc(50% - 3px),
|
|
||||||
calc(100% - 12px) calc(50% - 3px);
|
|
||||||
background-size: 6px 6px, 6px 6px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='file'].input {
|
input[type='file'].input {
|
||||||
@@ -54,9 +60,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 +122,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 +173,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 +237,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 +263,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,
|
||||||
|
|||||||
@@ -209,14 +209,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-close {
|
.toast-close {
|
||||||
@apply cursor-pointer border-0 bg-transparent text-xl;
|
@apply flex cursor-pointer items-center justify-center border-0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 120ms ease, opacity 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-close:hover {
|
.toast-close:hover {
|
||||||
transform: scale(1.08);
|
background: rgba(0, 0, 0, 0.08);
|
||||||
opacity: 0.84;
|
}
|
||||||
|
|
||||||
|
.toast-close:active {
|
||||||
|
background: rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-progress {
|
.toast-progress {
|
||||||
|
|||||||
@@ -302,10 +302,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
@apply grid items-center gap-2;
|
@apply grid items-center gap-1.5;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-head.selection-mode {
|
||||||
|
@apply justify-stretch;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.list-count {
|
.list-count {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
@apply w-auto whitespace-nowrap text-xs;
|
@apply w-auto whitespace-nowrap text-xs;
|
||||||
@@ -316,11 +321,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input {
|
.list-head .search-input {
|
||||||
@apply h-[42px] w-full min-w-0 rounded-[14px];
|
@apply h-[34px] w-full min-w-0 rounded-[10px] px-3 py-0 text-[13px] font-semibold;
|
||||||
|
line-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-row {
|
||||||
|
@apply grid min-w-0 gap-1.5;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .duplicate-mode-head-select {
|
||||||
|
@apply h-[34px] min-w-0 w-auto max-w-full rounded-[10px];
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-icon-btn {
|
.list-icon-btn {
|
||||||
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
|
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head.selection-mode > .btn.small {
|
||||||
|
@apply h-[34px] min-w-0 w-full justify-center gap-1 px-2 text-[12px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head.selection-mode > .btn.small .btn-icon {
|
||||||
|
@apply m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger {
|
||||||
|
@apply h-[34px] w-[34px] min-w-[34px] rounded-[10px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger.sort-trigger-labeled {
|
||||||
|
@apply h-[34px] w-[34px] min-w-[34px] gap-0 px-0 text-[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger.sort-trigger-labeled .btn-icon {
|
||||||
|
@apply m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-create-menu-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-head-menu .mobile-vault-filter-menu {
|
||||||
|
min-width: max(100%, 190px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar.actions {
|
.toolbar.actions {
|
||||||
@@ -329,6 +373,11 @@
|
|||||||
gap: var(--actions-gap);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar.actions.duplicates-toolbar {
|
||||||
|
@apply justify-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
gap: var(--actions-gap);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
@@ -337,6 +386,10 @@
|
|||||||
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
|
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-count-status {
|
||||||
|
@apply mb-1 px-1;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-fab-wrap {
|
.mobile-fab-wrap {
|
||||||
@apply fixed right-3.5 z-[45];
|
@apply fixed right-3.5 z-[45];
|
||||||
bottom: calc(14px + var(--mobile-tabbar-height, 70px) + env(safe-area-inset-bottom));
|
bottom: calc(14px + var(--mobile-tabbar-height, 70px) + env(safe-area-inset-bottom));
|
||||||
@@ -883,6 +936,10 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totp-code-main strong {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-module .field,
|
.settings-module .field,
|
||||||
.auth-card .field {
|
.auth-card .field {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -170,29 +184,156 @@
|
|||||||
gap: var(--actions-gap);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar.actions.duplicates-toolbar {
|
||||||
|
@apply items-center;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar .btn.small {
|
.toolbar .btn.small {
|
||||||
@apply h-[30px] rounded-full text-xs;
|
@apply h-[32px] rounded-full text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-select {
|
||||||
|
@apply h-8 min-w-[150px] rounded-full py-0 pl-3 pr-6 text-xs;
|
||||||
|
border-color: rgba(74, 103, 150, 0.26);
|
||||||
|
box-shadow: none;
|
||||||
|
line-height: 32px;
|
||||||
|
background-position:
|
||||||
|
calc(100% - 10px) calc(50% - 2px),
|
||||||
|
calc(100% - 6px) calc(50% - 2px);
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.input.duplicate-mode-toolbar-select {
|
||||||
|
height: 32px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-right: 24px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
line-height: 32px;
|
||||||
|
background-position:
|
||||||
|
calc(100% - 10px) calc(50% - 2px),
|
||||||
|
calc(100% - 6px) calc(50% - 2px);
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-head-select {
|
||||||
|
@apply h-[34px] w-auto min-w-[156px] max-w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-head-menu {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-toolbar-select {
|
||||||
|
@apply w-auto max-w-[170px] shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
@apply mb-2 flex items-center gap-2.5;
|
@apply mb-1.5 flex items-center gap-2;
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input-wrap {
|
.list-head.selection-mode {
|
||||||
|
@apply gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head.selection-mode > .btn.small {
|
||||||
|
@apply min-w-0 flex-1 justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .search-input-wrap,
|
||||||
|
.duplicate-mode-head-menu {
|
||||||
@apply min-w-0 flex-auto;
|
@apply min-w-0 flex-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input {
|
.list-head .search-input {
|
||||||
@apply h-[42px];
|
@apply h-[34px] rounded-[10px] px-3 py-0 text-[13px] font-semibold;
|
||||||
|
line-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .btn {
|
.list-head .btn {
|
||||||
@apply whitespace-nowrap;
|
@apply h-[34px] whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-row {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-control {
|
||||||
|
@apply relative min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger {
|
||||||
|
@apply flex h-[34px] w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-[10px] border px-2.5 py-0 text-left text-[13px] font-semibold;
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: rgba(74, 103, 150, 0.28);
|
||||||
|
color: var(--muted-strong);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.74);
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
color var(--dur-fast) var(--ease-smooth),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger:hover,
|
||||||
|
.mobile-vault-filter-trigger.active {
|
||||||
|
border-color: rgba(43, 102, 217, 0.46);
|
||||||
|
background: #f8fbff;
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger-icon,
|
||||||
|
.mobile-vault-filter-item-icon {
|
||||||
|
@apply inline-flex shrink-0 items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger-label {
|
||||||
|
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-chevron {
|
||||||
|
@apply shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger.active .mobile-vault-filter-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-menu {
|
||||||
|
@apply left-0 right-auto max-h-[280px] min-w-full overflow-auto;
|
||||||
|
min-width: max(100%, 168px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-control:last-child .mobile-vault-filter-menu {
|
||||||
|
@apply left-auto right-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-item {
|
||||||
|
@apply gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-item-main {
|
||||||
|
@apply flex min-w-0 items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-item-main span:last-child {
|
||||||
|
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -207,6 +348,10 @@
|
|||||||
@apply w-9 min-w-9 justify-center gap-0 p-0;
|
@apply w-9 min-w-9 justify-center gap-0 p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-trigger.sort-trigger-labeled {
|
||||||
|
@apply h-[34px] w-auto min-w-0 gap-1.5 rounded-[10px] px-3;
|
||||||
|
}
|
||||||
|
|
||||||
.sort-trigger.active {
|
.sort-trigger.active {
|
||||||
background: #e9f1ff;
|
background: #e9f1ff;
|
||||||
border-color: #a9c2ee;
|
border-color: #a9c2ee;
|
||||||
@@ -248,6 +393,30 @@
|
|||||||
@apply h-3.5 w-3.5 shrink-0;
|
@apply h-3.5 w-3.5 shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-create-menu-wrap {
|
||||||
|
@apply shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-create-trigger {
|
||||||
|
@apply h-[34px] w-[34px] min-w-[34px] gap-0 rounded-[10px] p-0 text-[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-create-trigger .btn-icon {
|
||||||
|
@apply m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicates-helper-toolbar {
|
||||||
|
@apply justify-start pb-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicates-helper-toolbar .btn.small {
|
||||||
|
@apply h-[34px] rounded-[10px] px-3 py-0 text-[13px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-count-status {
|
||||||
|
@apply mb-1 pl-1;
|
||||||
|
}
|
||||||
|
|
||||||
.list-panel {
|
.list-panel {
|
||||||
@apply min-h-0 overflow-auto p-2;
|
@apply min-h-0 overflow-auto p-2;
|
||||||
/* Virtualized rows update top/bottom spacers while scrolling. Chrome's scroll anchoring
|
/* Virtualized rows update top/bottom spacers while scrolling. Chrome's scroll anchoring
|
||||||
@@ -263,6 +432,11 @@
|
|||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item.duplicate-group-item {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 84% 94%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 42% 78%);
|
||||||
|
}
|
||||||
|
|
||||||
.list-item::before {
|
.list-item::before {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute inset-0 opacity-0;
|
@apply absolute inset-0 opacity-0;
|
||||||
@@ -343,6 +517,11 @@
|
|||||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04);
|
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item.duplicate-group-item:hover {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 88% 92%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 52% 68%);
|
||||||
|
}
|
||||||
|
|
||||||
.list-item:hover::before {
|
.list-item:hover::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
@@ -354,6 +533,11 @@
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item.duplicate-group-item.active {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 88% 89%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 58% 58%);
|
||||||
|
}
|
||||||
|
|
||||||
.list-item.active::before {
|
.list-item.active::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
@@ -525,7 +709,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 +758,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 +803,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 {
|
||||||
@@ -869,7 +1066,7 @@
|
|||||||
|
|
||||||
.totp-codes-list {
|
.totp-codes-list {
|
||||||
@apply grid w-full items-start gap-2.5;
|
@apply grid w-full items-start gap-2.5;
|
||||||
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr));
|
grid-template-columns: repeat(var(--totp-columns, 1), minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.totp-code-row {
|
.totp-code-row {
|
||||||
@@ -894,7 +1091,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.totp-code-main strong {
|
.totp-code-main strong {
|
||||||
@apply whitespace-nowrap text-[22px] leading-none;
|
@apply min-w-0 whitespace-nowrap text-[22px] leading-none;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||