16 Commits

Author SHA1 Message Date
shuaiplus 4246e179f1 Merge branch 'pr-200' 2026-04-23 23:22:01 +08:00
shuaiplus fe8d9e0b7d fix: harden API key authentication 2026-04-23 23:17:25 +08:00
maooyer 1147c1e013 feat(web): Add api key components 2026-04-23 23:17:25 +08:00
maooyer 31ffd98166 feat(server): Add api key handler 2026-04-23 23:17:25 +08:00
maooyer 7d7562d191 feat(server): Add api_key in backup repo 2026-04-23 23:17:25 +08:00
maooyer d6e5a1c40b feat(server): Add the field api_key at the database 2026-04-23 23:17:25 +08:00
shuaiplus 77794e43ce feat: remove unused styles for select input in dark theme 2026-04-22 23:50:25 +08:00
shuaiplus b990f17a3e Add new styles for app shell, tokens, and vault components
- Introduced `shell.css` for the main application layout, including styles for the app shell, top bar, and user interactions.
- Created `tokens.css` to define CSS variables for theming, including colors, shadows, and transition durations for light and dark modes.
- Developed `vault.css` for the vault component, implementing grid layouts, sidebar styles, search inputs, and list item designs.
2026-04-22 23:44:51 +08:00
shuaiplus 31b8ec6f7d feat: update VaultListPanel styles for improved item display and adjust row height for better layout 2026-04-22 21:39:15 +08:00
shuaiplus ef47597be5 feat: update website branding with new logo and wordmark, enhance styles for better responsiveness 2026-04-18 21:44:27 +08:00
shuaiplus 408874ac05 feat: update version to 1.4.4 in package.json, package-lock.json, and app-version.ts 2026-04-18 04:02:49 +08:00
shuaiplus dabd2c923e feat: optimize attachment handling in backup process 2026-04-18 03:55:27 +08:00
shuaiplus 08414d7cf2 feat: add support for new cipher properties and enhance import functionality 2026-04-18 03:44:17 +08:00
shuaiplus 38b33df719 feat: add password history feature with dialog and encryption handling 2026-04-18 02:05:01 +08:00
shuaiplus 7ebd12fa07 feat: add device note and last seen tracking to devices, enhance device management features 2026-04-18 01:43:21 +08:00
entsalze f7cbdaf730 feat: update NodeWarden logo image 2026-04-17 15:19:54 +08:00
58 changed files with 5772 additions and 5142 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 472 KiB

+4
View File
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users (
verify_devices INTEGER NOT NULL DEFAULT 1, verify_devices INTEGER NOT NULL DEFAULT 1,
totp_secret TEXT, totp_secret TEXT,
totp_recovery_code TEXT, totp_recovery_code TEXT,
api_key TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -153,12 +154,15 @@ CREATE TABLE IF NOT EXISTS devices (
encrypted_user_key TEXT, encrypted_user_key TEXT,
encrypted_public_key TEXT, encrypted_public_key TEXT,
encrypted_private_key TEXT, encrypted_private_key TEXT,
device_note TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier), PRIMARY KEY (user_id, device_identifier),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
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 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,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.3", "version": "1.4.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.3", "version": "1.4.4",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.3", "version": "1.4.4",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.3'; export const APP_VERSION = '1.4.4';
+3
View File
@@ -24,6 +24,9 @@
// Default PBKDF2 iterations for account creation/prelogin fallback. // Default PBKDF2 iterations for account creation/prelogin fallback.
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。 // 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
defaultKdfIterations: 600000, defaultKdfIterations: 600000,
// clientSecret length
// clientSecret 长度
clientSecretLength: 30,
}, },
rateLimit: { rateLimit: {
// Max failed login attempts before temporary lock. // Max failed login attempts before temporary lock.
+66
View File
@@ -209,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
verifyDevices: true, verifyDevices: true,
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
apiKey: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -751,3 +752,68 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
// POST /api/accounts/api-key
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
return apiKey(request, env, userId, false);
}
// POST /api/accounts/rotate-api-key
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
return apiKey(request, env, userId, true);
}
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): 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, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return errorResponse('Invalid JSON', 400);
}
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) {
// Upstream apikeys are 30-character random alphanumeric strings
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
if (rotate) {
user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id);
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
}
return jsonResponse({
apiKey: user.apiKey,
revisionDate: user.updatedAt,
object: 'apiKey',
});
}
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
function randomStringAlphanum(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(length);
crypto.getRandomValues(array);
let result = '';
for (let i = 0; i < length; i++) {
result += chars[array[i] % chars.length];
}
return result;
}
+20 -18
View File
@@ -218,26 +218,28 @@ async function executeConfiguredBackup(
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail', : 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
}); });
const remoteSession = createRemoteBackupTransferSession(destination); const remoteSession = createRemoteBackupTransferSession(destination);
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession); if (destination.includeAttachments) {
let attachmentIndexChanged = false; const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
for (const attachment of archive.manifest.attachmentBlobs || []) { let attachmentIndexChanged = false;
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) { for (const attachment of archive.manifest.attachmentBlobs || []) {
continue; if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
continue;
}
const remotePath = `attachments/${attachment.blobName}`;
const object = await getBlobObject(env, attachment.blobName);
if (!object) {
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
}
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await remoteSession.putFile(remotePath, bytes, {
contentType: object.contentType,
});
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
attachmentIndexChanged = true;
} }
const remotePath = `attachments/${attachment.blobName}`; if (attachmentIndexChanged) {
const object = await getBlobObject(env, attachment.blobName); await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
if (!object) {
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
} }
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await remoteSession.putFile(remotePath, bytes, {
contentType: object.contentType,
});
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
attachmentIndexChanged = true;
}
if (attachmentIndexChanged) {
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
} }
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null; let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
+82 -33
View File
@@ -1,4 +1,15 @@
import { Env, Cipher, CipherResponse, Attachment } from '../types'; import {
Env,
Cipher,
CipherCard,
CipherIdentity,
CipherLogin,
CipherResponse,
CipherSecureNote,
CipherSshKey,
Attachment,
PasswordHistory,
} from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
@@ -13,26 +24,6 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function mergeCipherNestedObject<T>(
existingValue: T | null | undefined,
incomingValue: unknown
): T | null {
if (incomingValue === undefined) {
return (existingValue ?? null) as T | null;
}
if (incomingValue === null || typeof incomingValue !== 'object' || Array.isArray(incomingValue)) {
return incomingValue as T | null;
}
const existingObject =
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
? (existingValue as Record<string, unknown>)
: {};
return {
...existingObject,
...(incomingValue as Record<string, unknown>),
} as T;
}
async function notifyVaultSyncForRequest( async function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
@@ -52,6 +43,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined }; return { present: false, value: undefined };
} }
function readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
}
function normalizeCipherTimestamp(value: unknown): string | null { function normalizeCipherTimestamp(value: unknown): string | null {
if (value == null || value === '') return null; if (value == null || value === '') return null;
const parsed = new Date(String(value)); const parsed = new Date(String(value));
@@ -64,6 +59,19 @@ function readCipherArchivedAt(source: any, fallback: string | null = null): stri
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback; return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
} }
function readCipherRevisionDate(source: any): string | null {
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
}
function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean {
if (!clientRevisionDate) return false;
const existingTs = Date.parse(existingUpdatedAt);
const clientTs = Date.parse(clientRevisionDate);
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
return existingTs - clientTs > 1000;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher { function syncCipherComputedAliases(cipher: Cipher): Cipher {
cipher.archivedDate = cipher.archivedAt ?? null; cipher.archivedDate = cipher.archivedAt ?? null;
cipher.deletedDate = cipher.deletedAt ?? null; cipher.deletedDate = cipher.deletedAt ?? null;
@@ -151,8 +159,8 @@ export function cipherToResponse(
// Server-computed / enforced fields (always override) // Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId), folderId: normalizeOptionalId(cipher.folderId),
type: Number(cipher.type) || 1, type: Number(cipher.type) || 1,
organizationId: null, organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: false, organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt, creationDate: createdAt,
revisionDate: updatedAt, revisionDate: updatedAt,
deletedDate: deletedAt, deletedDate: deletedAt,
@@ -163,12 +171,12 @@ export function cipherToResponse(
delete: true, delete: true,
restore: true, restore: true,
}, },
object: 'cipher', object: 'cipherDetails',
collectionIds: [], collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
login: normalizedLogin, login: normalizedLogin,
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
encryptedFor: null, encryptedFor: (passthrough as any).encryptedFor ?? null,
}; };
} }
@@ -251,6 +259,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// Handle nested cipher object (from some clients) // Handle nested cipher object (from some clients)
// Android client sends PascalCase "Cipher" for organization ciphers // Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body; const cipherData = body.Cipher || body.cipher || body;
const createFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
const createKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
const createLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
const createCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const now = new Date().toISOString(); const now = new Date().toISOString();
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones, // Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
@@ -268,6 +284,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, null), archivedAt: readCipherArchivedAt(cipherData, null),
deletedAt: null, deletedAt: null,
}; };
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
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);
@@ -307,6 +331,21 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Handle nested cipher object // Handle nested cipher object
// Android client sends PascalCase "Cipher" for organization ciphers // Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body; const cipherData = body.Cipher || body.cipher || body;
const incomingFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
const incomingKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
const incomingLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
const incomingCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
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.
// Unknown/future fields from the client are preserved; server-controlled fields are protected. // Unknown/future fields from the client are preserved; server-controlled fields are protected.
@@ -316,7 +355,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Server-controlled fields (never from client) // Server-controlled fields (never from client)
id: existingCipher.id, id: existingCipher.id,
userId: existingCipher.userId, userId: existingCipher.userId,
type: Number(cipherData.type) || existingCipher.type, type: nextType,
favorite: cipherData.favorite ?? existingCipher.favorite, favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt, createdAt: existingCipher.createdAt,
@@ -324,11 +363,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
cipher.login = mergeCipherNestedObject(existingCipher.login, cipherData.login); if (incomingFolderId.present) {
cipher.card = mergeCipherNestedObject(existingCipher.card, cipherData.card); cipher.folderId = normalizeOptionalId(incomingFolderId.value);
cipher.identity = mergeCipherNestedObject(existingCipher.identity, cipherData.identity); }
cipher.secureNote = mergeCipherNestedObject(existingCipher.secureNote, cipherData.secureNote); if (incomingKey.present) {
cipher.sshKey = mergeCipherNestedObject(existingCipher.sshKey, cipherData.sshKey); cipher.key = incomingKey.value ?? null;
}
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
if (incomingPasswordHistory.present) {
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
}
// Custom fields deletion compatibility: // Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields". // - Accept both camelCase "fields" and PascalCase "Fields".
@@ -351,9 +399,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, attachments)
); );
} }
+47 -4
View File
@@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
} }
function buildDeviceResponse(device: Device): DeviceResponse { function buildDeviceResponse(device: Device): DeviceResponse {
const displayName = String(device.deviceNote || '').trim() || device.name;
const response = { const response = {
Id: device.deviceIdentifier, Id: device.deviceIdentifier,
id: device.deviceIdentifier, id: device.deviceIdentifier,
UserId: device.userId, UserId: device.userId,
userId: device.userId, userId: device.userId,
Name: device.name, Name: displayName,
name: device.name, name: displayName,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier, Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier, identifier: device.deviceIdentifier,
Type: device.type, Type: device.type,
@@ -38,6 +43,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt, creationDate: device.createdAt,
RevisionDate: device.updatedAt, RevisionDate: device.updatedAt,
revisionDate: device.updatedAt, revisionDate: device.updatedAt,
LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt,
HasStoredDevice: true,
hasStoredDevice: true,
IsTrusted: isTrustedDevice(device), IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device), isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey, EncryptedUserKey: device.encryptedUserKey,
@@ -55,8 +64,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
const response = { const response = {
Id: device.deviceIdentifier, Id: device.deviceIdentifier,
id: device.deviceIdentifier, id: device.deviceIdentifier,
Name: device.name, Name: String(device.deviceNote || '').trim() || device.name,
name: device.name, name: String(device.deviceNote || '').trim() || device.name,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier, Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier, identifier: device.deviceIdentifier,
Type: device.type, Type: device.type,
@@ -101,6 +114,10 @@ async function readJsonBody(request: Request): Promise<any> {
} }
} }
function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128);
}
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior: // Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding // - X-Request-Email: base64url(email) without padding
@@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedPublicKey: null, encryptedPublicKey: null,
encryptedPrivateKey: null, encryptedPrivateKey: null,
devicePendingAuthRequest: null, devicePendingAuthRequest: null,
deviceNote: null,
lastSeenAt: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
}; };
data.push({ data.push({
...buildDeviceResponse(placeholderDevice), ...buildDeviceResponse(placeholderDevice),
isTrusted: true, isTrusted: true,
hasStoredDevice: false,
online: onlineSet.has(row.deviceIdentifier), online: onlineSet.has(row.deviceIdentifier),
trusted: true, trusted: true,
trustedTokenCount: row.tokenCount, trustedTokenCount: row.tokenCount,
@@ -269,6 +289,29 @@ export async function handleDeleteDevice(
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
// PUT /api/devices/:deviceIdentifier/name
export async function handleUpdateDeviceName(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const body = await readJsonBody(request);
const name = parseDeviceName(body?.name);
if (!name) return errorResponse('Device name is required', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateDeviceName(userId, normalized, name);
if (!updated) return errorResponse('Device not found', 404);
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
return jsonResponse(buildDeviceResponse(device));
}
// DELETE /api/devices // DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request; void request;
+120
View File
@@ -48,6 +48,18 @@ function parseCookieValue(request: Request, name: string): string | null {
return null; return null;
} }
function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
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 = [
@@ -361,6 +373,98 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} else if (grantType === 'client_credentials') {
// Login with client credentials
const clientId = body.client_id;
const clientSecret = body.client_secret;
const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = `${clientIdentifier}:${clientId}`;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400);
}
// Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
const uid = clientId.slice(5);
const user = await storage.getUserById(uid);
if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
// Persist device only after successful client credential verification.
const deviceSession =
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
}
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'send_access') { } else if (grantType === 'send_access') {
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
if (!sendAccessLimit.allowed) { if (!sendAccessLimit.allowed) {
@@ -450,6 +554,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
const { accessToken, user, device } = result; const { accessToken, user, device } = result;
if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier);
}
const newRefreshToken = await auth.generateRefreshToken(user.id, device); const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
@@ -550,3 +657,16 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
? withWebRefreshCookie(request, baseResponse, null) ? withWebRefreshCookie(request, baseResponse, null)
: baseResponse; : baseResponse;
} }
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
if (scope !== 'api') {
return false;
}
if (!clientId.startsWith('user.')) {
return false;
}
if (!clientSecret) {
return false;
}
return true;
}
+60 -43
View File
@@ -82,6 +82,16 @@ function bindNull(v: any): any {
return v === undefined ? null : v; return v === undefined ? null : v;
} }
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
if (!source || typeof source !== 'object') return undefined;
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return source[key] as T;
}
}
return undefined;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> { async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; i += chunkSize) { for (let i = 0; i < statements.length; i += chunkSize) {
const chunk = statements.slice(i, i + chunkSize); const chunk = statements.slice(i, i + chunkSize);
@@ -158,9 +168,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = []; const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) { for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i]; const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || c.folderId || null; const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
const sourceIdRaw = String(c?.id ?? '').trim(); const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null; const sourceId = sourceIdRaw || null;
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
const cipher: Cipher = { const cipher: Cipher = {
...c, ...c,
@@ -171,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
name: c.name ?? 'Untitled', name: c.name ?? 'Untitled',
notes: c.notes ?? null, notes: c.notes ?? null,
favorite: c.favorite ?? false, favorite: c.favorite ?? false,
login: c.login ? { login: login ? {
...c.login, ...login,
username: c.login.username ?? null, username: login.username ?? null,
password: c.login.password ?? null, password: login.password ?? null,
uris: c.login.uris?.map(u => ({ uris: login.uris?.map((u: any) => ({
...u, ...u,
uri: u.uri ?? null, uri: u.uri ?? null,
uriChecksum: null, uriChecksum: null,
match: u.match ?? null, match: u.match ?? null,
})) || null, })) || null,
totp: c.login.totp ?? null, totp: login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, autofillOnPageLoad: login.autofillOnPageLoad ?? null,
fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null, fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
uri: c.login.uri ?? null, uri: login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null, passwordRevisionDate: login.passwordRevisionDate ?? null,
} : null, } : null,
card: c.card ? { card: card ? {
...c.card, ...card,
cardholderName: c.card.cardholderName ?? null, cardholderName: card.cardholderName ?? null,
brand: c.card.brand ?? null, brand: card.brand ?? null,
number: c.card.number ?? null, number: card.number ?? null,
expMonth: c.card.expMonth ?? null, expMonth: card.expMonth ?? null,
expYear: c.card.expYear ?? null, expYear: card.expYear ?? null,
code: c.card.code ?? null, code: card.code ?? null,
} : null, } : null,
identity: c.identity ? { identity: identity ? {
...c.identity, ...identity,
title: c.identity.title ?? null, title: identity.title ?? null,
firstName: c.identity.firstName ?? null, firstName: identity.firstName ?? null,
middleName: c.identity.middleName ?? null, middleName: identity.middleName ?? null,
lastName: c.identity.lastName ?? null, lastName: identity.lastName ?? null,
address1: c.identity.address1 ?? null, address1: identity.address1 ?? null,
address2: c.identity.address2 ?? null, address2: identity.address2 ?? null,
address3: c.identity.address3 ?? null, address3: identity.address3 ?? null,
city: c.identity.city ?? null, city: identity.city ?? null,
state: c.identity.state ?? null, state: identity.state ?? null,
postalCode: c.identity.postalCode ?? null, postalCode: identity.postalCode ?? null,
country: c.identity.country ?? null, country: identity.country ?? null,
company: c.identity.company ?? null, company: identity.company ?? null,
email: c.identity.email ?? null, email: identity.email ?? null,
phone: c.identity.phone ?? null, phone: identity.phone ?? null,
ssn: c.identity.ssn ?? null, ssn: identity.ssn ?? null,
username: c.identity.username ?? null, username: identity.username ?? null,
passportNumber: c.identity.passportNumber ?? null, passportNumber: identity.passportNumber ?? null,
licenseNumber: c.identity.licenseNumber ?? null, licenseNumber: identity.licenseNumber ?? null,
} : null, } : null,
secureNote: c.secureNote ?? null, secureNote: secureNote ?? null,
fields: c.fields?.map(f => ({ fields: fields?.map((f: any) => ({
...f, ...f,
name: f.name ?? null, name: f.name ?? null,
value: f.value ?? null, value: f.value ?? null,
type: f.type, type: f.type,
linkedId: f.linkedId ?? null, linkedId: f.linkedId ?? null,
})) || null, })) || null,
passwordHistory: c.passwordHistory ?? null, passwordHistory: passwordHistory ?? null,
reprompt: c.reprompt ?? 0, reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null), sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null, key: key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
archivedAt: null, archivedAt: null,
+10
View File
@@ -11,6 +11,8 @@ import {
handleGetTotpStatus, handleGetTotpStatus,
handleSetTotpStatus, handleSetTotpStatus,
handleGetTotpRecoveryCode, handleGetTotpRecoveryCode,
handleGetApiKey,
handleRotateApiKey,
} from './handlers/accounts'; } from './handlers/accounts';
import { import {
handleGetCiphers, handleGetCiphers,
@@ -119,6 +121,14 @@ export async function handleAuthenticatedRoute(
return handleSetVerifyDevices(request, env, userId); return handleSetVerifyDevices(request, env, userId);
} }
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
return handleGetApiKey(request, env, userId);
}
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
return handleRotateApiKey(request, env, userId);
}
if (path === '/api/sync' && method === 'GET') { if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId); return handleSync(request, env, userId);
} }
+7
View File
@@ -13,6 +13,7 @@ import {
handleRevokeTrustedDevice, handleRevokeTrustedDevice,
handleDeleteAllDevices, handleDeleteAllDevices,
handleDeleteDevice, handleDeleteDevice,
handleUpdateDeviceName,
handleUpdateDeviceToken, handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth, handleUpdateDeviceWebPushAuth,
handleClearDeviceToken, handleClearDeviceToken,
@@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier); return handleDeleteDevice(request, env, userId, deviceIdentifier);
} }
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
}
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i); const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (identifierMatch && method === 'GET') { if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]); const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
+19 -8
View File
@@ -52,12 +52,12 @@ function isSameOriginWriteRequest(request: Request): boolean {
return false; return false;
} }
function getNwIconSvg(): string { function getDefaultWebsiteIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
} }
function handleNwFavicon(): Response { function handleNwFavicon(): Response {
return new Response(getNwIconSvg(), { return new Response(getDefaultWebsiteIconSvg(), {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'image/svg+xml; charset=utf-8', 'Content-Type': 'image/svg+xml; charset=utf-8',
@@ -66,6 +66,15 @@ function handleNwFavicon(): Response {
}); });
} }
function handleMissingWebsiteIcon(): Response {
return new Response(null, {
status: 404,
headers: {
'Cache-Control': 'public, max-age=300',
},
});
}
function buildIconServiceBase(origin: string): string { function buildIconServiceBase(origin: string): string {
return `${origin}/icons`; return `${origin}/icons`;
} }
@@ -104,6 +113,7 @@ function buildConfigResponse(origin: string) {
_icon_service_url: buildIconServiceTemplate(origin), _icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin), _icon_service_csp: buildIconServiceCsp(origin),
featureStates: { featureStates: {
'cipher-key-encryption': true,
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true, 'email-verification': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,
@@ -126,9 +136,9 @@ function normalizeIconHost(rawHost: string): string | null {
} }
} }
async function handleWebsiteIcon(host: string): Promise<Response> { async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
const normalizedHost = normalizeIconHost(host); const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return handleNwFavicon(); if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
const encodedHost = encodeURIComponent(normalizedHost); const encodedHost = encodeURIComponent(normalizedHost);
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' }; const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
@@ -171,9 +181,9 @@ async function handleWebsiteIcon(host: string): Promise<Response> {
}); });
} }
return handleNwFavicon(); return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
} catch { } catch {
return handleNwFavicon(); return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
} }
} }
@@ -220,7 +230,8 @@ export async function handlePublicRoute(
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') { if (iconMatch && method === 'GET') {
return handleWebsiteIcon(iconMatch[1]); const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode);
} }
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i); const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
+1 -1
View File
@@ -347,7 +347,7 @@ export async function buildBackupArchive(
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([ const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = 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, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders 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, 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'),
+1 -1
View File
@@ -594,7 +594,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
buildInsertStatements( buildInsertStatements(
db, db,
tableName('users'), tableName('users'),
['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'], ['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', 'api_key', 'created_at', 'updated_at'],
payload.users || [] payload.users || []
) )
); );
+39 -6
View File
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
userId: row.user_id, userId: row.user_id,
deviceIdentifier: row.device_identifier, deviceIdentifier: row.device_identifier,
name: row.name, name: row.name,
deviceNote: row.device_note ?? null,
type: row.type, type: row.type,
sessionStamp: row.session_stamp || '', sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null, encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null,
lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -33,31 +35,62 @@ export async function upsertDevice(
} }
): Promise<void> { ): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || ''; const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at' 'updated_at=excluded.updated_at'
) )
.bind( .bind(
userId, userId,
deviceIdentifier, deviceIdentifier,
name, effectiveName,
type, type,
effectiveSessionStamp, effectiveSessionStamp,
keys?.encryptedUserKey ?? null, keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null, keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null, keys?.encryptedPrivateKey ?? null,
existingDevice?.deviceNote ?? null,
now,
now, now,
now now
) )
.run(); .run();
} }
export async function updateDeviceName(
db: D1Database,
userId: string,
deviceIdentifier: string,
name: string
): Promise<boolean> {
const result = await db
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
.bind(String(name || '').trim(), userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function touchDeviceLastSeen(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function updateDeviceKeys( export async function updateDeviceKeys(
db: D1Database, db: D1Database,
userId: string, userId: string,
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> { export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db const res = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
) )
.bind(userId) .bind(userId)
.all<any>(); .all<any>();
@@ -144,7 +177,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> { export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db const row = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
) )
.bind(userId, deviceIdentifier) .bind(userId, deviceIdentifier)
+6 -2
View File
@@ -6,13 +6,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN api_key TEXT',
'CREATE TABLE IF NOT EXISTS user_revisions (' + 'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
@@ -73,7 +74,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + 'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' + 'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -84,6 +85,9 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note 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 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, ' +
+9 -6
View File
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
const USER_SELECT_COLUMNS = const USER_SELECT_COLUMNS =
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' + '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, ' + 'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
'totp_secret, totp_recovery_code, created_at, updated_at'; 'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
function mapUserRow(row: any): User { function mapUserRow(row: any): User {
return { return {
@@ -26,6 +26,7 @@ function mapUserRow(row: any): User {
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
apiKey: row.api_key ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -64,11 +65,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(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) ' + 'INSERT INTO users(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, api_key, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
); );
await safeBind( await safeBind(
stmt, stmt,
@@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.verifyDevices ? 1 : 0, user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.apiKey,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
).run(); ).run();
@@ -102,8 +104,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> { export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(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) ' + 'INSERT INTO users(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, api_key, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await safeBind( const result = await safeBind(
@@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.verifyDevices ? 1 : 0, user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.apiKey,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
).run(); ).run();
+11 -1
View File
@@ -92,7 +92,9 @@ import {
isKnownDevice as getKnownStoredDevice, isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail, isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
} from './storage-device-repo'; } from './storage-device-repo';
import { import {
@@ -106,7 +108,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-03-30.1'; const STORAGE_SCHEMA_VERSION = '2026-04-22';
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -550,6 +552,14 @@ export class StorageService {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys); return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
} }
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
}
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> { async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
} }
+7
View File
@@ -50,6 +50,7 @@ export interface User {
verifyDevices?: boolean; verifyDevices?: boolean;
totpSecret: string | null; totpSecret: string | null;
totpRecoveryCode: string | null; totpRecoveryCode: string | null;
apiKey: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -189,12 +190,14 @@ export interface Device {
userId: string; userId: string;
deviceIdentifier: string; deviceIdentifier: string;
name: string; name: string;
deviceNote: string | null;
type: number; type: number;
sessionStamp: string; sessionStamp: string;
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
encryptedPrivateKey: string | null; encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -208,10 +211,14 @@ export interface DeviceResponse {
id: string; id: string;
userId?: string | null; userId?: string | null;
name: string; name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string; identifier: string;
type: number; type: number;
creationDate: string; creationDate: string;
revisionDate: string; revisionDate: string;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean; isTrusted: boolean;
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="862" height="101" viewBox="0 0 8620 1017" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z" fill="#006DF4"/>
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z" fill="#006DF4"/>
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z" fill="#006DF4"/>
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z" fill="#006DF4"/>
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z" fill="#006DF4"/>
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z" fill="#006DF4"/>
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z" fill="#006DF4"/>
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z" fill="#006DF4"/>
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z" fill="#006DF4"/>
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z" fill="#006DF4"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+11
View File
@@ -766,6 +766,14 @@ export default function App() {
), ),
}; };
} }
if (Array.isArray(cipher.passwordHistory)) {
nextCipher.passwordHistory = await Promise.all(
cipher.passwordHistory.map(async (entry) => ({
...entry,
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac),
}))
);
}
if (cipher.card) { if (cipher.card) {
nextCipher.card = { nextCipher.card = {
...cipher.card, ...cipher.card,
@@ -1195,7 +1203,10 @@ export default function App() {
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onRemoveDevice: accountSecurityActions.openRemoveDevice, onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
@@ -33,7 +33,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<header className="topbar"> <header className="topbar">
<div className="brand"> <div className="brand">
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" /> <img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
<span className="brand-name">NodeWarden</span> <img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="brand-wordmark" />
<span className="mobile-page-title">{props.currentPageTitle}</span> <span className="mobile-page-title">{props.currentPageTitle}</span>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
+6
View File
@@ -94,7 +94,10 @@ export interface AppMainRoutesProps {
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onRefreshAuthorizedDevices: () => Promise<void>; onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAllDeviceTrust: () => void; onRevokeAllDeviceTrust: () => void;
@@ -224,6 +227,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onEnableTotp={props.onEnableTotp} onEnableTotp={props.onEnableTotp}
onOpenDisableTotp={props.onOpenDisableTotp} onOpenDisableTotp={props.onOpenDisableTotp}
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey}
onNotify={props.onNotify} onNotify={props.onNotify}
/> />
</Suspense> </Suspense>
@@ -281,6 +286,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
devices={props.authorizedDevices} devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading} loading={props.authorizedDevicesLoading}
onRefresh={() => void props.onRefreshAuthorizedDevices()} onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust} onRevokeTrust={props.onRevokeDeviceTrust}
onRemoveDevice={props.onRemoveDevice} onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust} onRevokeAll={props.onRevokeAllDeviceTrust}
+79 -11
View File
@@ -1,4 +1,6 @@
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import type { AuthorizedDevice } from '@/lib/types'; import type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
devices: AuthorizedDevice[]; devices: AuthorizedDevice[];
loading: boolean; loading: boolean;
onRefresh: () => void; onRefresh: () => void;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void; onRevokeTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void; onRevokeAll: () => void;
@@ -41,9 +44,26 @@ function mapDeviceTypeName(type: number): string {
} }
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
const [deviceNote, setDeviceNote] = useState('');
const [savingNote, setSavingNote] = useState(false);
async function handleSaveDeviceNote(): Promise<void> {
if (!editingDevice || savingNote) return;
setSavingNote(true);
try {
await props.onRenameDevice(editingDevice, deviceNote);
setEditingDevice(null);
setDeviceNote('');
} finally {
setSavingNote(false);
}
}
return ( return (
<div className="stack"> <>
<section className="card"> <div className="stack">
<section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3> <h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
@@ -66,9 +86,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button> </button>
</div> </div>
</div> </div>
</section> </section>
<section className="card"> <section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3> <h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<table className="table"> <table className="table">
<thead> <thead>
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tr key={device.identifier}> <tr key={device.identifier}>
<td data-label={t('txt_device')}> <td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div> <div>{device.name || t('txt_unknown_device')}</div>
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
<div className="muted-inline">{device.systemName}</div>
)}
<div className="muted-inline">{device.identifier}</div> <div className="muted-inline">{device.identifier}</div>
</td> </td>
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td> <td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</span> </span>
</td> </td>
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td> <td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td> <td data-label={t('txt_last_seen')}>{formatDateTime(device.lastSeenAt || device.revisionDate)}</td>
<td data-label={t('txt_trusted_until')}> <td data-label={t('txt_trusted_until')}>
{device.trusted ? ( {device.trusted ? (
<div className="trusted-cell"> <div className="trusted-cell">
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
onClick={() => props.onRevokeTrust(device)} onClick={() => props.onRevokeTrust(device)}
> >
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_trust')} {t('txt_untrust')}
</button> </button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}> <button
type="button"
className="btn btn-secondary small"
disabled={device.hasStoredDevice === false}
onClick={() => {
setEditingDevice(device);
setDeviceNote(device.deviceNote || device.name || '');
}}
>
<Pencil size={14} className="btn-icon" />
{t('txt_device_note')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={device.hasStoredDevice === false}
onClick={() => props.onRemoveDevice(device)}
>
<Trash2 size={14} className="btn-icon" /> <Trash2 size={14} className="btn-icon" />
{t('txt_remove_device_2')} {t('txt_delete')}
</button> </button>
</div> </div>
</td> </td>
@@ -135,7 +175,35 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
)} )}
</tbody> </tbody>
</table> </table>
</section> </section>
</div> </div>
<ConfirmDialog
open={!!editingDevice}
title={t('txt_device_note')}
message={t('txt_replace_device_name_with_note')}
confirmText={t('txt_save')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={savingNote}
cancelDisabled={savingNote}
onConfirm={() => void handleSaveDeviceNote()}
onCancel={() => {
if (savingNote) return;
setEditingDevice(null);
setDeviceNote('');
}}
>
<label className="field">
<span>{t('txt_device_note')}</span>
<input
className="input"
maxLength={128}
value={deviceNote}
onInput={(e) => setDeviceNote((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
</>
); );
} }
+124
View File
@@ -14,6 +14,8 @@ interface SettingsPageProps {
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onNotify?: (type: 'success' | 'error', text: string) => void; onNotify?: (type: 'success' | 'error', text: string) => void;
} }
@@ -48,6 +50,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState(''); const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState(''); const [recoveryCode, setRecoveryCode] = useState('');
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
const [apiKey, setApiKey] = useState('');
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!props.totpEnabled) { if (!props.totpEnabled) {
@@ -87,6 +93,27 @@ export default function SettingsPage(props: SettingsPageProps) {
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
} }
async function loadApiKey(): Promise<void> {
try {
const key = await props.onGetApiKey(apiKeyMasterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
}
}
async function doRotateApiKey(): Promise<void> {
try {
const key = await props.onRotateApiKey(apiKeyMasterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated'));
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
}
}
function formatDateTime(value: string | null | undefined): string { function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash'); if (!value) return t('txt_dash');
const parsed = new Date(value); const parsed = new Date(value);
@@ -235,8 +262,105 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
)} )}
</div> </div>
<div className="settings-subcard">
<h3>{t('txt_api_key')}</h3>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={apiKeyMasterPassword}
onInput={(e) => setApiKeyMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadApiKey()}>
<KeyRound size={14} className="btn-icon" />
{t('txt_view_api_key')}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setRotateApiKeyConfirmOpen(true)}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_rotate_api_key')}
</button>
</div>
</div>
</div> </div>
</section> </section>
<ConfirmDialog
open={apiKeyDialogOpen}
title={t('txt_api_key')}
message={t('txt_api_key_dialog_intro')}
hideCancel
confirmText={t('txt_close')}
onConfirm={() => setApiKeyDialogOpen(false)}
onCancel={() => setApiKeyDialogOpen(false)}
>
<div
style={{
border: '1px solid color-mix(in srgb, var(--danger) 24%, transparent)',
background: 'color-mix(in srgb, var(--danger) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginTop: 12,
marginBottom: 14,
}}
>
<div style={{ fontWeight: 800, color: 'var(--danger)', marginBottom: 8 }}>{t('txt_warning')}</div>
<div style={{ color: 'var(--text)', lineHeight: 1.55 }}>{t('txt_api_key_warning_body')}</div>
</div>
<div
style={{
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 800, color: 'var(--primary)', marginBottom: 10 }}>
<KeyRound size={15} />
<span>{t('txt_oauth_client_credentials')}</span>
</div>
{([
[t('txt_client_id'), `user.${props.profile.id}`],
[t('txt_client_secret'), apiKey],
[t('txt_scope'), 'api'],
[t('txt_grant_type'), 'client_credentials'],
] as [string, string][]).map(([label, value]) => (
<label key={label} className="field">
<span>{label}</span>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: 8 }}>
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
<button
type="button"
className="btn btn-secondary small"
onClick={() => void copyTextToClipboard(value, { successMessage: t('txt_copied') })}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy')}
</button>
</div>
</label>
))}
</div>
</ConfirmDialog>
<ConfirmDialog
open={rotateApiKeyConfirmOpen}
title={t('txt_rotate_api_key')}
message={t('txt_rotate_api_key_confirm')}
danger
onConfirm={() => {
setRotateApiKeyConfirmOpen(false);
void doRotateApiKey();
}}
onCancel={() => setRotateApiKeyConfirmOpen(false)}
/>
</div> </div>
); );
} }
@@ -12,7 +12,7 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<div className="standalone-brand standalone-brand-outside"> <div className="standalone-brand standalone-brand-outside">
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" /> <img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
<div> <div>
<div className="standalone-brand-title">NodeWarden</div> <img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="standalone-brand-wordmark" />
</div> </div>
</div> </div>
+4
View File
@@ -65,6 +65,10 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher); const uri = firstCipherUri(cipher);
const host = hostFromUri(uri); const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
}, [host]);
if (host && !errored) { if (host && !errored) {
return ( return (
<img <img
@@ -1,5 +1,7 @@
import { useState } from 'preact/hooks'; import { createPortal } from 'preact/compat';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact'; import { useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -35,10 +37,60 @@ interface VaultDetailViewProps {
onUnarchive: (cipher: Cipher) => void | Promise<void>; onUnarchive: (cipher: Cipher) => void | Promise<void>;
} }
function PasswordHistoryDialog(props: {
open: boolean;
entries: Array<{ password: string; lastUsedDate: string | null }>;
onClose: () => void;
}) {
useDialogLifecycle(props.open, props.onClose);
if (!props.open || typeof document === 'undefined') return null;
return createPortal(
<div className="dialog-mask open" onClick={(event) => event.target === event.currentTarget && props.onClose()}>
<section className="dialog-card password-history-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_password_history')}>
<div className="password-history-head">
<h3 className="dialog-title">{t('txt_password_history')}</h3>
<button type="button" className="password-history-close" aria-label={t('txt_close')} onClick={props.onClose}>
<X size={18} />
</button>
</div>
<div className="password-history-list">
{props.entries.map((entry, index) => (
<div key={`password-history-${index}-${entry.lastUsedDate || 'none'}`} className="password-history-item">
<div className="password-history-copy">
<button type="button" className="btn btn-secondary small password-history-copy-btn" onClick={() => copyToClipboard(entry.password)}>
<Clipboard size={16} />
</button>
</div>
<div className="password-history-value">{entry.password}</div>
<div className="password-history-time">{formatHistoryTime(entry.lastUsedDate)}</div>
</div>
))}
</div>
<button type="button" className="btn btn-primary dialog-btn" onClick={props.onClose}>
{t('txt_close')}
</button>
</section>
</div>,
document.body
);
}
export default function VaultDetailView(props: VaultDetailViewProps) { export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const passwordHistoryEntries = useMemo(
() =>
(props.selectedCipher.passwordHistory || [])
.map((entry) => ({
password: String(entry?.decPassword || entry?.password || ''),
lastUsedDate: entry?.lastUsedDate ?? null,
}))
.filter((entry) => entry.password.trim()),
[props.selectedCipher.passwordHistory]
);
const formatDownloadLabel = (attachmentId: string) => { const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -355,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<h4>{t('txt_item_history')}</h4> <h4>{t('txt_item_history')}</h4>
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div> <div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div> <div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
{!!props.selectedCipher.login?.passwordRevisionDate && (
<div className="detail-sub">{t('txt_password_updated_value', { value: formatHistoryTime(props.selectedCipher.login.passwordRevisionDate) })}</div>
)}
{passwordHistoryEntries.length > 0 && (
<button type="button" className="password-history-link" onClick={() => setPasswordHistoryOpen(true)}>
{t('txt_password_history')}
</button>
)}
</div> </div>
)} )}
@@ -379,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
</div> </div>
</> </>
)} )}
<PasswordHistoryDialog
open={passwordHistoryOpen}
entries={passwordHistoryEntries}
onClose={() => setPasswordHistoryOpen(false)}
/>
</> </>
); );
} }
@@ -185,11 +185,10 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<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.filteredCiphers.length && ( {!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}> <div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher, index) => ( {props.visibleCiphers.map((cipher) => (
<div <div
key={cipher.id} key={cipher.id}
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
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;
@@ -1,4 +1,4 @@
import { useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { import {
CreditCard, CreditCard,
FileKey2, FileKey2,
@@ -37,7 +37,7 @@ export const CREATE_TYPE_OPTIONS: TypeOption[] = [
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1'; export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)'; export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
export const VAULT_LIST_ROW_HEIGHT = 66; export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10; export const VAULT_LIST_OVERSCAN = 10;
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
{ value: 'edited', label: t('txt_sort_last_edited') }, { value: 'edited', label: t('txt_sort_last_edited') },
@@ -161,7 +161,7 @@ export function hostFromUri(uri: string): string {
} }
export function websiteIconUrl(host: string): string { export function websiteIconUrl(host: string): string {
return `/icons/${encodeURIComponent(host)}/icon.png`; return `/icons/${encodeURIComponent(host)}/icon.png?fallback=404`;
} }
export function createEmptyLoginUri(): VaultDraftLoginUri { export function createEmptyLoginUri(): VaultDraftLoginUri {
@@ -433,6 +433,10 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher); const uri = firstCipherUri(cipher);
const host = hostFromUri(uri); const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
}, [host]);
if (host && !errored) { if (host && !errored) {
return ( return (
<img <img
+69 -15
View File
@@ -5,10 +5,13 @@ import {
deleteAuthorizedDevice, deleteAuthorizedDevice,
deriveLoginHash, deriveLoginHash,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
getApiKey,
getTotpRecoveryCode, getTotpRecoveryCode,
rotateApiKey,
revokeAuthorizedDeviceTrust, revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust,
setTotp, setTotp,
updateAuthorizedDeviceName,
updateProfile, updateProfile,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -147,10 +150,45 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
return code; return code;
}, },
async getApiKey(masterPassword: string): Promise<string> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const key = await getApiKey(authedFetch, derived.hash);
if (!key) throw new Error(t('txt_api_key_is_empty'));
return key;
},
async rotateApiKey(masterPassword: string): Promise<string> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const key = await rotateApiKey(authedFetch, derived.hash);
if (!key) throw new Error(t('txt_api_key_is_empty'));
return key;
},
async refreshAuthorizedDevices() { async refreshAuthorizedDevices() {
await refetchAuthorizedDevices(); await refetchAuthorizedDevices();
}, },
async renameAuthorizedDevice(device: AuthorizedDevice, name: string) {
const normalized = String(name || '').trim();
if (!normalized) {
onNotify('error', t('txt_device_note_required'));
return;
}
try {
await updateAuthorizedDeviceName(authedFetch, device.identifier, normalized);
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_note_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_device_note_failed'));
}
},
openRevokeDeviceTrust(device: AuthorizedDevice) { openRevokeDeviceTrust(device: AuthorizedDevice) {
onSetConfirm({ onSetConfirm({
title: t('txt_revoke_device_authorization'), title: t('txt_revoke_device_authorization'),
@@ -159,9 +197,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); try {
await refetchAuthorizedDevices(); await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
onNotify('success', t('txt_device_authorization_revoked')); await refetchAuthorizedDevices();
onNotify('success', t('txt_device_authorization_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
}
})(); })();
}, },
}); });
@@ -175,14 +217,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await deleteAuthorizedDevice(authedFetch, device.identifier); try {
if (device.identifier === getCurrentDeviceIdentifier()) { await deleteAuthorizedDevice(authedFetch, device.identifier);
if (device.identifier === getCurrentDeviceIdentifier()) {
onNotify('success', t('txt_device_removed'));
onLogoutNow();
return;
}
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_removed')); onNotify('success', t('txt_device_removed'));
onLogoutNow(); } catch (error) {
return; onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
} }
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_removed'));
})(); })();
}, },
}); });
@@ -196,9 +242,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await revokeAllAuthorizedDeviceTrust(authedFetch); try {
await refetchAuthorizedDevices(); await revokeAllAuthorizedDeviceTrust(authedFetch);
onNotify('success', t('txt_all_device_authorizations_revoked')); await refetchAuthorizedDevices();
onNotify('success', t('txt_all_device_authorizations_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed'));
}
})(); })();
}, },
}); });
@@ -212,9 +262,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await deleteAllAuthorizedDevices(authedFetch); try {
onNotify('success', t('txt_all_devices_removed')); await deleteAllAuthorizedDevices(authedFetch);
onLogoutNow(); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
}
})(); })();
}, },
}); });
+43
View File
@@ -575,7 +575,50 @@ export async function deleteAuthorizedDevice(
if (!resp.ok) throw new Error(t('txt_remove_device_failed')); if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
} }
export async function updateAuthorizedDeviceName(
authedFetch: AuthedFetch,
deviceIdentifier: string,
name: string
): Promise<void> {
const normalized = String(name || '').trim();
if (!normalized) throw new Error(t('txt_device_note_required'));
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: normalized }),
});
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
}
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' }); const resp = await authedFetch('/api/devices', { method: 'DELETE' });
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
} }
export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
const resp = await authedFetch('/api/accounts/api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Failed to get API key');
}
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
return String(body.apiKey || '');
}
export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
const resp = await authedFetch('/api/accounts/rotate-api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Failed to rotate API key');
}
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
return String(body.apiKey || '');
}
+7 -1
View File
@@ -14,7 +14,13 @@ export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<V
if (existing) return existing; if (existing) return existing;
const request = (async () => { const request = (async () => {
const resp = await authedFetch('/api/sync'); const resp = await authedFetch('/api/sync', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
});
if (!resp.ok) throw new Error('Failed to load vault'); if (!resp.ok) throw new Error('Failed to load vault');
const body = await parseJson<VaultSyncResponse>(resp); const body = await parseJson<VaultSyncResponse>(resp);
return body || {}; return body || {};
+61
View File
@@ -1,6 +1,7 @@
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
import type { import type {
Cipher, Cipher,
CipherPasswordHistoryEntry,
Folder, Folder,
SessionState, SessionState,
VaultDraft, VaultDraft,
@@ -346,6 +347,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
return encryptBw(new TextEncoder().encode(s), enc, mac); return encryptBw(new TextEncoder().encode(s), enc, mac);
} }
async function encryptPasswordHistory(
entries: CipherPasswordHistoryEntry[] | null | undefined,
enc: Uint8Array,
mac: Uint8Array
): Promise<CipherPasswordHistoryEntry[] | null> {
if (!Array.isArray(entries) || entries.length === 0) return null;
const out: CipherPasswordHistoryEntry[] = [];
for (const entry of entries) {
const rawPassword = String(entry?.password || '');
const plainPassword = entry?.decPassword ?? rawPassword;
const encryptedPassword = looksLikeCipherString(rawPassword)
? rawPassword
: await encryptTextValue(plainPassword, enc, mac);
if (!encryptedPassword) continue;
out.push({
password: encryptedPassword,
lastUsedDate: toIsoDateOrNow(entry?.lastUsedDate),
});
}
return out.length ? out : null;
}
async function buildUpdatedPasswordHistory(
cipher: Cipher | null,
draft: VaultDraft,
enc: Uint8Array,
mac: Uint8Array
): Promise<CipherPasswordHistoryEntry[] | null> {
const existingHistory = Array.isArray(cipher?.passwordHistory) ? cipher.passwordHistory : [];
const currentPassword = String(cipher?.login?.decPassword || '');
const nextPassword = String(draft.loginPassword || '');
const passwordChanged = currentPassword !== nextPassword;
const history = await encryptPasswordHistory(existingHistory, enc, mac);
if (!passwordChanged || !currentPassword.trim()) {
return history;
}
const encryptedCurrentPassword = await encryptTextValue(currentPassword, enc, mac);
if (!encryptedCurrentPassword) {
return history;
}
const nextEntries: CipherPasswordHistoryEntry[] = [
{
password: encryptedCurrentPassword,
lastUsedDate: new Date().toISOString(),
},
...(history || []),
];
return nextEntries.slice(0, 5);
}
async function encryptCustomFields( async function encryptCustomFields(
fields: VaultDraftField[], fields: VaultDraftField[],
enc: Uint8Array, enc: Uint8Array,
@@ -473,6 +529,7 @@ async function buildCipherPayload(
const userMac = base64ToBytes(session.symMacKey); const userMac = base64ToBytes(session.symMacKey);
const keys = await getCipherKeys(cipher, userEnc, userMac); const keys = await getCipherKeys(cipher, userEnc, userMac);
const type = Number(draft.type || cipher?.type || 1); const type = Number(draft.type || cipher?.type || 1);
const now = new Date().toISOString();
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
type, type,
@@ -487,6 +544,7 @@ async function buildCipherPayload(
secureNote: null, secureNote: null,
sshKey: null, sshKey: null,
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac), fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
}; };
if (cipher?.id) { if (cipher?.id) {
@@ -495,6 +553,7 @@ async function buildCipherPayload(
} }
if (type === 1) { if (type === 1) {
const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || '');
const existingFido2 = const existingFido2 =
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials) cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials ? (cipher.login as any).fido2Credentials
@@ -508,9 +567,11 @@ async function buildCipherPayload(
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac), totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
passwordRevisionDate: passwordChanged ? now : existingLogin.passwordRevisionDate ?? null,
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac), fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac), uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
}; };
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
} else if (type === 3) { } else if (type === 3) {
payload.card = { payload.card = {
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac), cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
+46
View File
@@ -387,6 +387,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_device: "Device", txt_device: "Device",
txt_device_authorization_revoked: "Device trust revoked", txt_device_authorization_revoked: "Device trust revoked",
txt_device_management: "Device Management", txt_device_management: "Device Management",
txt_device_note: "Device Note",
txt_device_note_required: "Device name is required",
txt_device_note_updated: "Device name updated",
txt_device_removed: "Device removed", txt_device_removed: "Device removed",
txt_load_devices_failed: "Failed to load devices", txt_load_devices_failed: "Failed to load devices",
txt_disable_this_send: "Disable this send", txt_disable_this_send: "Disable this send",
@@ -459,6 +462,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_item_created: "Item created", txt_item_created: "Item created",
txt_item_deleted: "Item deleted", txt_item_deleted: "Item deleted",
txt_item_history: "Item History", txt_item_history: "Item History",
txt_password_history: "Password History",
txt_password_updated_value: "Password updated: {value}",
txt_item_name_is_required: "Item name is required.", txt_item_name_is_required: "Item name is required.",
txt_item_updated: "Item updated", txt_item_updated: "Item updated",
txt_last_edited_value: "Last edited: {value}", txt_last_edited_value: "Last edited: {value}",
@@ -550,6 +555,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_not_trusted: "Not trusted", txt_not_trusted: "Not trusted",
txt_note: "Note", txt_note: "Note",
txt_notes: "Notes", txt_notes: "Notes",
txt_replace_device_name_with_note: "Set a custom name for this device without changing its detected system type.",
txt_number: "Number", txt_number: "Number",
txt_open: "Open", txt_open: "Open",
txt_opera_browser: "Opera Browser", txt_opera_browser: "Opera Browser",
@@ -595,6 +601,21 @@ const messages: Record<Locale, Record<string, string>> = {
txt_recovery_code_copied: "Recovery code copied", txt_recovery_code_copied: "Recovery code copied",
txt_recovery_code_is_empty: "Recovery code is empty", txt_recovery_code_is_empty: "Recovery code is empty",
txt_recovery_code_loaded: "Recovery code loaded", txt_recovery_code_loaded: "Recovery code loaded",
txt_api_key: "API Key",
txt_view_api_key: "View API Key",
txt_rotate_api_key: "Rotate API Key",
txt_api_key_copied: "API key copied",
txt_api_key_loaded: "API key loaded",
txt_api_key_rotated: "API key rotated",
txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.",
txt_api_key_is_empty: "API key is empty",
txt_api_key_dialog_intro: "Your API key can be used to authenticate with the Bitwarden CLI.",
txt_api_key_warning_body: "Your API key is an alternative authentication mechanism. Keep it secret.",
txt_oauth_client_credentials: "OAuth 2.0 Client Credentials",
txt_client_id: "client_id",
txt_client_secret: "client_secret",
txt_scope: "scope",
txt_grant_type: "grant_type",
txt_refresh: "Refresh", txt_refresh: "Refresh",
txt_refresh_in_seconds_s: "Refresh in {seconds}s", txt_refresh_in_seconds_s: "Refresh in {seconds}s",
txt_regenerate: "Regenerate", txt_regenerate: "Regenerate",
@@ -618,6 +639,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_revoke_device_trust_failed: "Failed to revoke device trust", txt_revoke_device_trust_failed: "Failed to revoke device trust",
txt_revoke_all_device_trust_failed: "Failed to revoke all device trust", txt_revoke_all_device_trust_failed: "Failed to revoke all device trust",
txt_revoke_trust: "Revoke Trust", txt_revoke_trust: "Revoke Trust",
txt_untrust: "Untrust",
txt_update_device_note_failed: "Update device note failed",
txt_role: "Role", txt_role: "Role",
txt_save: "Save", txt_save: "Save",
txt_save_profile: "Save Profile", txt_save_profile: "Save Profile",
@@ -1067,7 +1090,10 @@ const zhCNOverrides: Record<string, string> = {
txt_additional_options: '附加选项', txt_additional_options: '附加选项',
txt_custom_fields: '自定义字段', txt_custom_fields: '自定义字段',
txt_notes: '备注', txt_notes: '备注',
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
txt_item_history: '项目历史', txt_item_history: '项目历史',
txt_password_history: '密码历史记录',
txt_password_updated_value: '密码新于: {value}',
txt_last_edited_value: '最后编辑:{value}', txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}', txt_created_value: '创建于:{value}',
txt_username: '用户名', txt_username: '用户名',
@@ -1113,12 +1139,17 @@ const zhCNOverrides: Record<string, string> = {
txt_view_recovery_code: '查看恢复代码', txt_view_recovery_code: '查看恢复代码',
txt_copy_code: '复制代码', txt_copy_code: '复制代码',
txt_device_management: '设备管理', txt_device_management: '设备管理',
txt_device_note: '备注',
txt_device_note_required: '设备名称不能为空',
txt_device_note_updated: '设备名称已更新',
txt_authorized_devices: '已授权设备', txt_authorized_devices: '已授权设备',
txt_device: '设备', txt_device: '设备',
txt_last_seen: '最后在线', txt_last_seen: '最后在线',
txt_trusted_until: '信任至', txt_trusted_until: '信任至',
txt_revoke_trust: '撤销信任', txt_revoke_trust: '撤销信任',
txt_untrust: '不信任',
txt_remove_device_2: '移除设备', txt_remove_device_2: '移除设备',
txt_update_device_note_failed: '更新设备备注失败',
txt_not_trusted: '未信任', txt_not_trusted: '未信任',
txt_unknown_device: '未知设备', txt_unknown_device: '未知设备',
txt_users: '用户', txt_users: '用户',
@@ -1347,6 +1378,21 @@ const zhCNOverrides: Record<string, string> = {
txt_recovery_code_copied: '恢复代码已复制', txt_recovery_code_copied: '恢复代码已复制',
txt_recovery_code_is_empty: '恢复代码为空', txt_recovery_code_is_empty: '恢复代码为空',
txt_recovery_code_loaded: '恢复代码已加载', txt_recovery_code_loaded: '恢复代码已加载',
txt_api_key: 'API 密钥',
txt_view_api_key: '查看 API 密钥',
txt_rotate_api_key: '轮换 API 密钥',
txt_api_key_copied: 'API 密钥已复制',
txt_api_key_loaded: 'API 密钥已加载',
txt_api_key_rotated: 'API 密钥已轮换',
txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。',
txt_api_key_is_empty: 'API 密钥为空',
txt_api_key_dialog_intro: '您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。',
txt_api_key_warning_body: '您的 API 密钥是一种替代身份验证机制。请严格保密。',
txt_oauth_client_credentials: 'OAuth 2.0 客户端凭据',
txt_client_id: 'client_id',
txt_client_secret: 'client_secret',
txt_scope: 'scope',
txt_grant_type: 'grant_type',
txt_refresh_in_seconds_s: '{seconds} 秒后刷新', txt_refresh_in_seconds_s: '{seconds} 秒后刷新',
txt_registration_succeeded_please_sign_in: '注册成功,请登录', txt_registration_succeeded_please_sign_in: '注册成功,请登录',
txt_remove_device: '移除设备', txt_remove_device: '移除设备',
+11 -1
View File
@@ -148,6 +148,12 @@ export interface CipherField {
decValue?: string; decValue?: string;
} }
export interface CipherPasswordHistoryEntry {
password?: string | null;
lastUsedDate?: string | null;
decPassword?: string;
}
export interface Cipher { export interface Cipher {
id: string; id: string;
type: number; type: number;
@@ -167,7 +173,7 @@ export interface Cipher {
identity?: CipherIdentity | null; identity?: CipherIdentity | null;
sshKey?: CipherSshKey | null; sshKey?: CipherSshKey | null;
secureNote?: { type?: number | null } | null; secureNote?: { type?: number | null } | null;
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null; passwordHistory?: CipherPasswordHistoryEntry[] | null;
fields?: CipherField[] | null; fields?: CipherField[] | null;
decName?: string; decName?: string;
decNotes?: string; decNotes?: string;
@@ -338,10 +344,14 @@ export interface AdminInvite {
export interface AuthorizedDevice { export interface AuthorizedDevice {
id: string; id: string;
name: string; name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string; identifier: string;
type: number; type: number;
creationDate: string | null; creationDate: string | null;
revisionDate: string | null; revisionDate: string | null;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
online: boolean; online: boolean;
trusted: boolean; trusted: boolean;
trustedTokenCount: number; trustedTokenCount: number;
+12 -4977
View File
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
.loading-screen {
height: 100%;
display: grid;
place-items: center;
color: var(--muted);
font-size: 18px;
animation: fade-in-up var(--dur-panel) var(--ease-out-strong) both;
}
.auth-page {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
position: relative;
background: transparent;
}
.public-send-page {
min-height: 80vh;
align-items: center;
justify-items: center;
}
.auth-card {
width: 100%;
position: relative;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow-lg);
padding: 30px;
overflow: hidden;
transform-origin: 50% 24%;
animation: surface-enter 520ms var(--ease-out-strong) both;
}
.auth-card h1 {
margin: 0 0 4px 0;
text-align: center;
}
.standalone-shell {
width: min(640px, 100%);
display: grid;
gap: 14px;
animation: fade-in-up 420ms var(--ease-out-strong) both;
}
.standalone-brand {
display: inline-flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
}
.standalone-brand-outside {
justify-content: center;
width: 100%;
margin-bottom: 2px;
}
.standalone-brand-logo {
width: 56px;
height: 56px;
object-fit: contain;
flex-shrink: 0;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
}
.standalone-brand-wordmark {
display: block;
height: auto;
width: clamp(200px, 30vw, 360px);
max-width: 100%;
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.18));
}
.standalone-title {
margin: 0 0 4px 0;
text-align: left;
font-size: 31px;
line-height: 1.15;
letter-spacing: -0.035em;
}
.standalone-muted {
text-align: left;
}
.jwt-warning-head {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
color: #b45309;
text-align: center;
}
.jwt-warning-box {
border: 1px solid #f1d8a5;
border-radius: 12px;
background: #fffaf0;
padding: 12px 14px;
}
.jwt-warning-label {
font-size: 13px;
font-weight: 700;
color: #92400e;
margin-bottom: 6px;
}
.jwt-warning-copy {
margin: 0 0 14px;
color: #475569;
line-height: 1.6;
}
.jwt-warning-list {
margin: 0;
padding-left: 18px;
color: #334155;
line-height: 1.55;
}
.jwt-inline-link {
color: #1d4ed8;
font-weight: 700;
text-decoration: none;
}
.jwt-inline-link:hover {
text-decoration: underline;
}
.jwt-secret-fields {
margin-top: 8px;
display: grid;
gap: 6px;
}
.jwt-secret-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.jwt-secret-row > span {
color: #64748b;
}
.jwt-generator {
margin-top: 14px;
}
.jwt-generator-actions {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.jwt-copy-hint {
color: #15803d;
font-size: 13px;
font-weight: 700;
}
.standalone-footer {
width: 100%;
text-align: center;
font-size: 13px;
color: #64748b;
}
.standalone-footer a {
color: #1d4ed8;
font-weight: 700;
text-decoration: none;
}
.standalone-footer a:hover {
text-decoration: underline;
}
.standalone-version {
font-weight: 700;
color: #1d4ed8;
}
+27
View File
@@ -0,0 +1,27 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
color: var(--text);
background: var(--bg-accent);
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
}
body {
position: relative;
transition:
background-color var(--dur-medium) var(--ease-smooth),
color var(--dur-medium) var(--ease-smooth);
}
body.dialog-open {
overflow: hidden;
overscroll-behavior: contain;
}
+451
View File
@@ -0,0 +1,451 @@
:root[data-theme='dark'] body,
:root[data-theme='dark'] #root,
:root[data-theme='dark'] .app-page,
:root[data-theme='dark'] .auth-page {
background: transparent;
color: var(--text);
}
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .auth-card,
:root[data-theme='dark'] .dialog,
:root[data-theme='dark'] .jwt-warning-box,
:root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar,
:root[data-theme='dark'] .backup-detail-panel,
:root[data-theme='dark'] .settings-subcard,
:root[data-theme='dark'] .list-panel,
:root[data-theme='dark'] .card,
:root[data-theme='dark'] .sidebar-block,
:root[data-theme='dark'] .empty {
background: var(--panel);
border-color: var(--line);
color: var(--text);
box-shadow: var(--shadow-lg);
}
:root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .mobile-tabbar,
:root[data-theme='dark'] .sort-menu,
:root[data-theme='dark'] .create-menu,
:root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .mobile-sidebar-sheet,
:root[data-theme='dark'] .mobile-detail-sheet {
background: var(--panel-soft);
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .dialog-card.warning {
border-color: rgba(248, 113, 113, 0.36);
background: linear-gradient(180deg, rgba(39, 16, 16, 0.98), rgba(27, 12, 12, 0.98));
box-shadow:
0 36px 90px rgba(5, 5, 5, 0.56),
0 0 0 1px rgba(248, 113, 113, 0.12) inset;
}
:root[data-theme='dark'] .dialog-mask.warning {
background:
radial-gradient(circle at top, rgba(127, 29, 29, 0.28), transparent 34%),
linear-gradient(180deg, rgba(20, 12, 12, 0.64), rgba(2, 6, 23, 0.82));
}
:root[data-theme='dark'] .dialog-warning-badge {
background: linear-gradient(180deg, rgba(127, 29, 29, 0.8), rgba(69, 10, 10, 0.86));
color: #fda4af;
box-shadow:
0 12px 30px rgba(0, 0, 0, 0.32),
0 0 0 1px rgba(248, 113, 113, 0.14) inset;
}
:root[data-theme='dark'] .dialog-warning-kicker,
:root[data-theme='dark'] .dialog-card.warning .dialog-title {
color: #fecaca;
}
:root[data-theme='dark'] .dialog-message.warning {
border-color: rgba(248, 113, 113, 0.18);
background: linear-gradient(180deg, rgba(69, 10, 10, 0.54), rgba(67, 20, 7, 0.46));
color: #fecdd3;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18) inset;
}
:root[data-theme='dark'] .app-side,
:root[data-theme='dark'] .sidebar,
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
background: var(--panel-muted);
border-color: var(--line);
}
:root[data-theme='dark'] .auth-card {
background: var(--panel);
}
:root[data-theme='dark'] .brand,
:root[data-theme='dark'] .mobile-page-title,
:root[data-theme='dark'] .detail-title,
:root[data-theme='dark'] .dialog-title,
:root[data-theme='dark'] .standalone-title,
:root[data-theme='dark'] .kv-main strong,
:root[data-theme='dark'] .list-title,
:root[data-theme='dark'] .sidebar-title,
:root[data-theme='dark'] h1,
:root[data-theme='dark'] h2,
:root[data-theme='dark'] h3,
:root[data-theme='dark'] h4 {
color: var(--text);
}
:root[data-theme='dark'] .standalone-brand-wordmark,
:root[data-theme='dark'] .brand-wordmark {
text-shadow: 0 16px 28px rgba(2, 6, 23, 0.32);
}
:root[data-theme='dark'] .muted,
:root[data-theme='dark'] .detail-sub,
:root[data-theme='dark'] .field-help,
:root[data-theme='dark'] .list-sub,
:root[data-theme='dark'] .kv-label,
:root[data-theme='dark'] .standalone-muted,
:root[data-theme='dark'] .standalone-footer,
:root[data-theme='dark'] .backup-inline-note,
:root[data-theme='dark'] .backup-browser-empty,
:root[data-theme='dark'] .or,
:root[data-theme='dark'] .mobile-tab,
:root[data-theme='dark'] .side-link,
:root[data-theme='dark'] .user-chip,
:root[data-theme='dark'] .list-count {
color: var(--muted);
}
:root[data-theme='dark'] .user-chip {
background: rgba(17, 34, 56, 0.94);
border-color: var(--line);
box-shadow: 0 12px 24px rgba(1, 7, 18, 0.24);
}
:root[data-theme='dark'] .side-link:hover,
:root[data-theme='dark'] .mobile-tab:hover {
background: rgba(132, 182, 255, 0.11);
}
:root[data-theme='dark'] .side-link.active,
:root[data-theme='dark'] .mobile-tab.active,
:root[data-theme='dark'] .sort-menu-item.active,
:root[data-theme='dark'] .list-item.active {
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.08));
border-color: rgba(132, 182, 255, 0.28);
color: var(--primary);
}
:root[data-theme='dark'] .input,
:root[data-theme='dark'] .textarea,
:root[data-theme='dark'] select.input,
:root[data-theme='dark'] .dialog input,
:root[data-theme='dark'] .dialog textarea,
:root[data-theme='dark'] .dialog select {
background: rgba(13, 24, 40, 0.94);
border-color: rgba(103, 136, 186, 0.36);
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
:root[data-theme='dark'] .input::placeholder,
:root[data-theme='dark'] .textarea::placeholder,
:root[data-theme='dark'] input::placeholder,
:root[data-theme='dark'] textarea::placeholder {
color: #7488a8;
}
:root[data-theme='dark'] .input:focus,
:root[data-theme='dark'] .textarea:focus,
:root[data-theme='dark'] .search-input:focus,
:root[data-theme='dark'] .dialog input:focus,
:root[data-theme='dark'] .dialog textarea:focus,
:root[data-theme='dark'] .dialog select:focus {
border-color: rgba(132, 182, 255, 0.54);
background-color: rgba(16, 30, 49, 0.98);
box-shadow: 0 0 0 4px rgba(132, 182, 255, 0.12), 0 10px 22px rgba(5, 13, 28, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
:root[data-theme='dark'] .input-readonly {
background: #0f1b2d;
color: var(--muted-strong);
}
:root[data-theme='dark'] .input:disabled,
:root[data-theme='dark'] .btn:disabled {
background: #132033;
border-color: #22334c;
color: #70829d;
}
:root[data-theme='dark'] .btn-secondary {
background: linear-gradient(180deg, rgba(22, 41, 66, 0.98), rgba(16, 31, 52, 0.98));
border-color: rgba(132, 182, 255, 0.22);
color: #a9cdff;
box-shadow: 0 12px 22px rgba(1, 7, 18, 0.18);
}
:root[data-theme='dark'] .btn-secondary:hover {
background: linear-gradient(180deg, rgba(26, 49, 79, 0.98), rgba(19, 37, 61, 0.98));
border-color: rgba(132, 182, 255, 0.3);
}
:root[data-theme='dark'] .btn-danger {
background: linear-gradient(180deg, rgba(45, 23, 33, 0.98), rgba(35, 18, 28, 0.98));
border-color: rgba(255, 139, 168, 0.38);
color: #ff9bb0;
}
:root[data-theme='dark'] .btn-danger:hover {
background: linear-gradient(180deg, rgba(56, 27, 40, 0.98), rgba(41, 19, 31, 0.98));
border-color: rgba(255, 171, 192, 0.42);
}
:root[data-theme='dark'] .btn-primary {
background: linear-gradient(135deg, #79acff, #57c2ff 76%);
border-color: rgba(176, 214, 255, 0.22);
color: #061120;
box-shadow: 0 18px 32px rgba(10, 26, 52, 0.34);
}
:root[data-theme='dark'] .btn-primary:hover {
background: linear-gradient(135deg, #90bcff, #6accff 76%);
box-shadow: 0 22px 36px rgba(10, 26, 52, 0.38);
}
:root[data-theme='dark'] .toolbar.actions,
:root[data-theme='dark'] .list-head,
:root[data-theme='dark'] .mobile-panel-head,
:root[data-theme='dark'] .backup-recommendation-header,
:root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar,
:root[data-theme='dark'] .backup-detail-panel,
:root[data-theme='dark'] .detail-actions,
:root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .app-side,
:root[data-theme='dark'] .kv-row,
:root[data-theme='dark'] .attachment-row,
:root[data-theme='dark'] .backup-browser-row {
border-color: var(--line);
}
:root[data-theme='dark'] .input,
:root[data-theme='dark'] .search-input,
:root[data-theme='dark'] .list-item,
:root[data-theme='dark'] .sidebar-block {
background: rgba(15, 28, 45, 0.94);
}
:root[data-theme='dark'] .sidebar,
:root[data-theme='dark'] .content,
:root[data-theme='dark'] .list-col,
:root[data-theme='dark'] .detail-col {
color: var(--text);
}
:root[data-theme='dark'] .mobile-sidebar-mask,
:root[data-theme='dark'] .dialog-mask {
background: var(--overlay-strong);
}
:root[data-theme='dark'] .toast {
background: linear-gradient(180deg, rgba(19, 34, 54, 0.98), rgba(14, 26, 42, 0.98));
border-color: #263a57;
color: var(--text);
}
:root[data-theme='dark'] .toast.success {
background: #0f2a1f;
border-color: #1f5b44;
color: #9be2bd;
}
:root[data-theme='dark'] .toast.error {
background: #2a1720;
border-color: #6c2b41;
color: #ffb1c0;
}
:root[data-theme='dark'] .toast.warning {
background: #2d2413;
border-color: #7b6230;
color: #f7d48b;
}
:root[data-theme='dark'] .jwt-warning-head,
:root[data-theme='dark'] .jwt-warning-label,
:root[data-theme='dark'] .jwt-warning-copy,
:root[data-theme='dark'] .jwt-warning-list {
color: #f4d48a;
}
:root[data-theme='dark'] .theme-switch-input:focus + .theme-switch-slider {
box-shadow: 0 0 0 2px rgba(132, 182, 255, 0.24);
}
:root[data-theme='dark'] .search-input,
:root[data-theme='dark'] .list-head .search-input,
:root[data-theme='dark'] .mobile-settings-card,
:root[data-theme='dark'] .mobile-settings-link,
:root[data-theme='dark'] .table tr,
:root[data-theme='dark'] .settings-subcard,
:root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar,
:root[data-theme='dark'] .backup-detail-panel,
:root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .backup-browser-path,
:root[data-theme='dark'] .backup-browser-list,
:root[data-theme='dark'] .create-menu,
:root[data-theme='dark'] .create-menu-item,
:root[data-theme='dark'] .sort-menu,
:root[data-theme='dark'] .sort-menu-item,
:root[data-theme='dark'] .backup-recommendation-card,
:root[data-theme='dark'] .backup-recommendation-dav-item,
:root[data-theme='dark'] .backup-destination-item,
:root[data-theme='dark'] .totp-code-row,
:root[data-theme='dark'] .list-item {
background:
linear-gradient(180deg, rgba(18, 32, 52, 0.92), rgba(14, 26, 42, 0.92));
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .list-item:hover,
:root[data-theme='dark'] .sort-menu-item:hover,
:root[data-theme='dark'] .create-menu-item:hover,
:root[data-theme='dark'] .mobile-settings-link:hover,
:root[data-theme='dark'] .backup-destination-item:hover {
background:
linear-gradient(180deg, rgba(24, 44, 70, 0.96), rgba(16, 31, 51, 0.96));
border-color: rgba(118, 150, 197, 0.32);
}
:root[data-theme='dark'] .list-item.active {
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.1));
border-color: rgba(122, 176, 255, 0.34);
box-shadow: inset 0 0 0 1px rgba(200, 225, 255, 0.06), 0 12px 24px rgba(5, 13, 28, 0.18);
}
:root[data-theme='dark'] .list-item::before {
background:
linear-gradient(90deg, rgba(132, 182, 255, 0.08), transparent 24%, transparent 76%, rgba(56, 189, 248, 0.08)),
radial-gradient(circle at 18px 50%, rgba(255, 255, 255, 0.06), transparent 44%);
}
:root[data-theme='dark'] .backup-destination-item.active,
:root[data-theme='dark'] .backup-interval-preset.active,
:root[data-theme='dark'] .mobile-settings-link.active,
:root[data-theme='dark'] .tree-btn.active {
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.1));
border-color: rgba(132, 182, 255, 0.34);
color: #f4f8ff;
}
:root[data-theme='dark'] .theme-switch-slider {
background: linear-gradient(180deg, #1d3659, #142845);
border-color: rgba(120, 152, 198, 0.34);
}
:root[data-theme='dark'] .theme-switch-slider::before {
background: linear-gradient(180deg, #f8fbff, #dce9ff);
box-shadow: 0 3px 10px rgba(2, 8, 20, 0.28);
}
:root[data-theme='dark'] .theme-switch .moon svg {
fill: #8db6ff;
}
:root[data-theme='dark'] .theme-switch .sun svg {
opacity: 0.82;
}
:root[data-theme='dark'] .totp-code-name,
:root[data-theme='dark'] .backup-destination-name,
:root[data-theme='dark'] .backup-browser-entry,
:root[data-theme='dark'] .mobile-settings-link,
:root[data-theme='dark'] .backup-browser-path strong,
:root[data-theme='dark'] .backup-option-label,
:root[data-theme='dark'] .sort-menu-item,
:root[data-theme='dark'] .create-menu-item,
:root[data-theme='dark'] .tree-btn,
:root[data-theme='dark'] .folder-add-btn,
:root[data-theme='dark'] .list-icon-fallback,
:root[data-theme='dark'] .totp-code-main strong,
:root[data-theme='dark'] .totp-timer-value {
color: var(--text);
}
:root[data-theme='dark'] .totp-code-username,
:root[data-theme='dark'] .backup-destination-meta,
:root[data-theme='dark'] .backup-browser-meta,
:root[data-theme='dark'] .table td::before,
:root[data-theme='dark'] .backup-recommendation-step,
:root[data-theme='dark'] .backup-recommendation-inline-note,
:root[data-theme='dark'] .backup-recommendation-linked-item,
:root[data-theme='dark'] .backup-inline-suffix,
:root[data-theme='dark'] .folder-delete-btn,
:root[data-theme='dark'] .folder-add-btn:hover,
:root[data-theme='dark'] .tree-label,
:root[data-theme='dark'] .list-sub {
color: var(--muted);
}
:root[data-theme='dark'] .import-export-panel p,
:root[data-theme='dark'] .dialog-message,
:root[data-theme='dark'] .local-error,
:root[data-theme='dark'] .status-ok {
color: var(--muted);
}
:root[data-theme='dark'] .backup-destination-type {
background: #1d3048;
color: #c9d8eb;
}
:root[data-theme='dark'] .backup-help-trigger {
border-color: #38618f;
background: #173150;
color: #9ec5ff;
}
:root[data-theme='dark'] .backup-help-trigger:hover,
:root[data-theme='dark'] .backup-help-trigger:focus-visible {
border-color: #5f92d7;
background: #20426a;
}
:root[data-theme='dark'] .backup-help-bubble {
background: var(--panel);
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .backup-help-bubble::before {
background: var(--panel);
border-left-color: var(--line);
border-top-color: var(--line);
}
:root[data-theme='dark'] .table td {
border-bottom-color: #203047;
}
:root[data-theme='dark'] .local-error {
color: #ff9bb0;
}
:root[data-theme='dark'] .status-ok {
color: #9be2bd;
}
:root[data-theme='dark'] .totp-qr {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
}
:root[data-theme='dark'] .totp-qr svg,
:root[data-theme='dark'] .totp-qr img {
background: #ffffff;
border-radius: 8px;
}
+309
View File
@@ -0,0 +1,309 @@
.muted {
margin: 0 0 16px 0;
text-align: center;
color: var(--muted);
line-height: 1.65;
}
.field {
display: block;
margin-bottom: 14px;
}
.field > span {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
.input {
width: 100%;
height: 48px;
border: 1px solid rgba(74, 103, 150, 0.42);
border-radius: 14px;
padding: 10px 14px;
font-size: 16px;
outline: none;
color: var(--text);
background: var(--panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
transition:
border-color var(--dur-fast) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft);
}
select.input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 42px;
background-image:
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 {
height: auto;
min-height: 48px;
padding: 8px 10px;
font-size: 14px;
line-height: 1.4;
}
input[type='file'].input::file-selector-button {
height: 32px;
border: 1px solid #3f5b9e;
border-radius: 999px;
padding: 0 12px;
background: #eef4ff;
color: #1f4ea0;
font-weight: 700;
cursor: pointer;
margin-right: 10px;
}
input[type='file'].input::file-selector-button:hover {
background: #dfeaff;
border-color: #2f5fd8;
}
.textarea {
min-height: 110px;
height: auto;
resize: vertical;
}
.input:focus {
border-color: rgba(43, 102, 217, 0.6);
background-color: #fbfdff;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.11), 0 10px 20px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
}
.input-readonly {
background: #eef2f7;
color: #475569;
}
.input:disabled {
background: #e2e8f0;
border-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
}
.password-wrap {
position: relative;
}
.password-wrap .input {
padding-right: 44px;
}
.password-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border: none;
background: transparent;
color: #275ac2;
cursor: pointer;
display: grid;
place-items: center;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
}
.eye-btn {
position: absolute;
right: 10px;
bottom: 9px;
width: 30px;
height: 30px;
border: none;
background: transparent;
cursor: pointer;
display: grid;
place-items: center;
color: #334155;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
}
.password-toggle:hover,
.eye-btn:hover {
color: var(--primary);
transform: translateY(-1px) scale(1.04);
}
.btn {
height: 36px;
border: 1px solid transparent;
border-radius: 999px;
padding: 0 16px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
transition:
transform var(--dur-fast) var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
border-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
opacity var(--dur-fast) var(--ease-smooth);
}
.topbar-actions .btn,
.user-chip,
.side-link,
.mobile-tab {
--mag-x: 0px;
--mag-y: 0px;
position: relative;
overflow: hidden;
}
.topbar-actions .btn::before,
.user-chip::before,
.side-link::before,
.mobile-tab::before {
content: '';
position: absolute;
left: var(--mx, 50%);
top: var(--my, 50%);
width: 110px;
height: 110px;
border-radius: 999px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.36), rgba(255, 255, 255, 0.08) 42%, transparent 72%);
transform: translate(-50%, -50%) scale(0.68);
opacity: 0;
pointer-events: none;
transition:
opacity var(--dur-fast) var(--ease-smooth),
transform var(--dur-medium) var(--ease-out-soft);
}
.topbar-actions .btn:hover::before,
.user-chip:hover::before,
.side-link:hover::before,
.mobile-tab:hover::before {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.btn:hover:not(:disabled) {
transform: translateY(-2px) scale(1.01);
}
.btn:active:not(:disabled) {
transform: translateY(0) scale(0.985);
}
.btn-icon {
flex-shrink: 0;
}
.btn.full {
width: 100%;
height: 50px;
font-size: 22px;
margin: 10px 0;
}
.btn-primary {
background: linear-gradient(135deg, #2563eb, #3b82f6 72%);
border-color: rgba(15, 63, 152, 0.32);
color: #fff;
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24);
}
.btn-primary:hover {
background: linear-gradient(135deg, #1d4ed8, #3377f0 72%);
border-color: rgba(15, 63, 152, 0.38);
box-shadow: 0 18px 34px rgba(37, 99, 235, 0.28);
}
.btn-secondary {
background: var(--panel);
border-color: rgba(37, 99, 235, 0.22);
color: var(--primary-strong);
box-shadow: 0 8px 18px rgba(13, 31, 68, 0.05);
}
.btn-secondary:hover {
background: #f4f8ff;
border-color: rgba(37, 99, 235, 0.34);
}
.btn-danger {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(217, 45, 87, 0.28);
color: var(--danger);
}
.btn-danger:hover {
background: rgba(255, 241, 242, 0.96);
border-color: rgba(217, 45, 87, 0.38);
}
.btn:disabled {
background: #e2e8f0;
border-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
}
.or {
text-align: center;
margin: 10px 0;
color: #334155;
}
.field-help {
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
color: #667085;
}
.auth-support-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: -2px 0 12px;
}
.auth-link-btn {
border: none;
background: transparent;
padding: 0;
color: #1d4ed8;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.auth-link-btn:hover {
text-decoration: underline;
transform: translateX(2px);
}
.auth-link-btn:disabled {
color: #94a3b8;
cursor: not-allowed;
text-decoration: none;
}
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
@keyframes toast-life {
from {
transform: scaleX(1);
transform-origin: left center;
}
to {
transform: scaleX(0);
transform-origin: left center;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translate3d(0, 16px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes shell-enter {
from {
opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.992);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes surface-enter {
from {
opacity: 0;
transform: translate3d(0, 20px, 0) scale(0.985);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes menu-in {
from {
opacity: 0;
transform: translate3d(0, 10px, 0) scale(0.96);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes dialog-in {
from {
opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.96);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate3d(18px, 0, 0) scale(0.97);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes stagger-rise {
from {
opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.985);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes dialog-out {
from {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
to {
opacity: 0;
transform: translate3d(0, 10px, 0) scale(0.972);
}
}
@keyframes route-stage-in {
from {
opacity: 0;
transform: translate3d(0, 14px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
+299
View File
@@ -0,0 +1,299 @@
.dialog-mask {
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
background: rgba(15, 23, 42, 0.5);
display: grid;
place-items: center;
z-index: 1200;
padding: 20px;
opacity: 0;
animation: fade-in var(--dur-medium) var(--ease-smooth) both;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.dialog-card {
width: min(460px, 100%);
background: #fff;
border-radius: 20px;
border: 1px solid var(--line);
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
padding: 20px;
text-align: center;
transform-origin: 50% 30%;
animation: dialog-in 240ms var(--ease-out-strong) both;
}
.dialog-mask.warning {
background:
radial-gradient(circle at top, rgba(255, 237, 213, 0.32), transparent 34%),
linear-gradient(180deg, rgba(127, 29, 29, 0.36), rgba(15, 23, 42, 0.72));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.dialog-card.warning {
width: min(520px, 100%);
border: 1px solid rgba(220, 38, 38, 0.22);
background:
linear-gradient(180deg, rgba(255, 246, 246, 0.98), rgba(255, 255, 255, 0.99));
box-shadow:
0 36px 90px rgba(69, 10, 10, 0.28),
0 0 0 1px rgba(255, 255, 255, 0.7) inset;
}
.dialog-warning-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.dialog-warning-badge {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: linear-gradient(180deg, #fff1f2, #ffe4e6);
color: #dc2626;
box-shadow:
0 12px 30px rgba(220, 38, 38, 0.18),
0 0 0 1px rgba(220, 38, 38, 0.08) inset;
}
.dialog-warning-kicker {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #b91c1c;
}
.dialog-mask.closing {
animation: fade-out 220ms var(--ease-smooth) both;
}
.dialog-card.closing {
animation: dialog-out 220ms var(--ease-smooth) both;
}
.dialog-card .field {
text-align: left;
}
.dialog-title {
margin: 6px 0;
font-size: 30px;
}
.dialog-message {
color: #475467;
margin-bottom: 10px;
}
.dialog-card.warning .dialog-title {
color: #7f1d1d;
margin-bottom: 10px;
}
.dialog-message.warning {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(220, 38, 38, 0.16);
background: linear-gradient(180deg, rgba(255, 241, 242, 0.94), rgba(255, 247, 237, 0.9));
color: #7a2832;
line-height: 1.65;
box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset;
}
.dialog-btn {
width: 100%;
height: 50px;
font-size: 20px;
margin-top: 8px;
}
.dialog-extra {
margin-top: 8px;
}
.dialog-divider {
height: 1px;
background: var(--line);
margin: 8px 0 10px;
}
.import-summary-dialog {
max-width: 520px;
text-align: left;
position: relative;
padding-top: 16px;
}
.import-summary-close {
position: absolute;
top: 10px;
right: 10px;
border: none;
background: transparent;
color: #64748b;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.import-summary-close:hover {
color: #0f172a;
}
.import-summary-table-wrap {
margin-top: 8px;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
.import-summary-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.import-summary-table th,
.import-summary-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.import-summary-table th {
text-align: left;
color: #475467;
background: #f8fafc;
}
.import-summary-table td:last-child,
.import-summary-table th:last-child {
text-align: right;
width: 96px;
}
.import-summary-table tbody tr:last-child td {
border-bottom: none;
}
.import-summary-failed-list {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 10px;
background: #fef2f2;
color: #991b1b;
font-size: 13px;
}
.import-summary-failed-title {
font-weight: 700;
margin-bottom: 6px;
}
.import-summary-failed-list ul {
margin: 0;
padding-left: 18px;
}
.import-summary-failed-list li + li {
margin-top: 4px;
}
.settings-twofactor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-subcard {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: #fff;
}
.settings-subcard h3 {
margin-top: 0;
margin-bottom: 10px;
}
.toast-stack {
position: fixed;
top: 16px;
right: 16px;
z-index: 1400;
width: min(420px, calc(100vw - 20px));
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.toast-item {
position: relative;
border-radius: 10px;
border: 1px solid #bbdfc6;
background: #dff4e5;
color: #0f5132;
padding: 12px 14px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
animation: toast-in 240ms var(--ease-out-strong) both;
}
.toast-item.error {
border-color: #f2b8c1;
background: #fde7eb;
color: #9f1239;
}
.toast-item.warning {
border-color: #f2b8c1;
background: #fde7eb;
color: #9f1239;
}
.toast-text {
font-weight: 700;
padding-right: 10px;
}
.toast-close {
border: none;
background: transparent;
cursor: pointer;
font-size: 20px;
color: inherit;
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.toast-close:hover {
transform: scale(1.08);
opacity: 0.84;
}
.toast-progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background: rgba(15, 23, 42, 0.2);
animation: toast-life 4.5s linear forwards;
}
+32
View File
@@ -0,0 +1,32 @@
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
.btn:hover:not(:disabled),
.btn:active:not(:disabled),
.side-link:hover,
.tree-btn:hover,
.list-item:hover,
.list-item.active,
.search-input:focus,
.input:focus,
.password-toggle:hover,
.eye-btn:hover,
.auth-link-btn:hover,
.sort-menu-item:hover,
.create-menu-item:hover,
.toast-close:hover,
.mobile-sidebar-close:hover {
transform: none !important;
}
}
+790
View File
@@ -0,0 +1,790 @@
@media (max-width: 1180px) {
.app-page {
padding: 8px;
}
.app-shell {
height: calc(100vh - 16px);
border-radius: 12px;
}
.app-main {
grid-template-columns: 1fr;
}
.app-side {
border-right: none;
border-bottom: 1px solid #d9e0ea;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
align-self: start;
height: fit-content;
gap: 8px;
}
.app-side > .side-link {
min-height: 0;
}
.vault-grid {
grid-template-columns: 1fr;
height: auto;
}
.sidebar {
max-height: 280px;
}
.totp-grid,
.field-grid {
grid-template-columns: 1fr;
}
.totp-copy-btn {
justify-self: start;
}
.import-export-panels,
.backup-browser-row {
grid-template-columns: 1fr;
}
.settings-twofactor-grid {
grid-template-columns: 1fr;
}
.standalone-title {
font-size: 24px;
}
.standalone-footer {
font-size: 12px;
line-height: 1.4;
}
}
@media (max-width: 900px) {
.auth-page {
padding: 14px;
align-items: start;
}
.standalone-shell {
width: 100%;
max-width: 460px;
gap: 10px;
padding-top: 12px;
}
.standalone-brand-outside {
justify-content: flex-start;
}
.standalone-brand-logo {
width: 44px;
height: 44px;
}
.auth-card {
padding: 20px 16px;
border-radius: 18px;
}
.btn.full {
height: 48px;
font-size: 18px;
}
.auth-support-row {
align-items: center;
flex-direction: row;
}
.app-page {
padding: 0;
background: transparent;
}
.app-shell {
--mobile-topbar-height: 58px;
--mobile-tabbar-height: 70px;
height: 100dvh;
max-width: none;
border: none;
border-radius: 0;
box-shadow: none;
}
.topbar {
height: var(--mobile-topbar-height);
padding: 0 12px;
position: relative;
z-index: 20;
}
.brand {
min-width: 0;
gap: 10px;
font-size: 18px;
}
.brand-logo {
width: 34px;
height: 34px;
}
.mobile-page-title {
display: inline;
}
.topbar-actions .user-chip,
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn),
.topbar-actions > .theme-switch-wrap {
display: none;
}
.mobile-sidebar-toggle,
.mobile-lock-btn {
display: inline-flex;
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
justify-content: center;
font-size: 0;
gap: 0;
}
.mobile-sidebar-toggle .btn-icon,
.mobile-lock-btn .btn-icon {
margin: 0;
}
.mobile-theme-btn {
display: inline-flex;
align-items: center;
}
.mobile-theme-btn .theme-switch {
transform: scale(0.8);
transform-origin: center;
}
.app-main {
display: flex;
flex-direction: column;
min-height: 0;
}
.app-side {
display: none;
}
.content {
flex: 1;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.mobile-tabbar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 6px;
min-height: var(--mobile-tabbar-height);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: rgba(248, 251, 255, 0.92);
}
.mobile-tab {
display: grid;
justify-items: center;
gap: 4px;
color: #64748b;
text-decoration: none;
font-size: 11px;
font-weight: 700;
padding: 6px 4px;
border-radius: 12px;
transition:
transform 220ms var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth);
}
.mobile-tab:hover {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
}
.mobile-tab.active {
color: var(--primary-strong);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.16), rgba(59, 130, 246, 0.08));
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.08);
}
.vault-grid {
gap: 10px;
padding: 0;
}
.sidebar {
display: none;
}
.mobile-sidebar-sheet {
display: block;
position: fixed;
left: 10px;
right: 10px;
top: calc(var(--mobile-topbar-height) + 10px);
bottom: auto;
max-height: calc(100dvh - 145px);
z-index: 55;
overflow: auto;
border: 1px solid #d8dee8;
border-radius: 18px;
background: #fff;
padding: 12px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate3d(0, 10px, 0) scale(0.98);
transition:
opacity 220ms var(--ease-smooth),
transform 240ms var(--ease-out-soft),
visibility 220ms var(--ease-smooth);
}
.mobile-sidebar-sheet.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate3d(0, 0, 0) scale(1);
}
.mobile-sidebar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.mobile-sidebar-title {
font-size: 16px;
font-weight: 800;
color: #0f172a;
}
.mobile-sidebar-close {
width: 34px;
height: 34px;
border: 1px solid #d7dde6;
border-radius: 999px;
background: #fff;
color: #0f172a;
display: inline-grid;
place-items: center;
cursor: pointer;
padding: 0;
transition:
transform var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
border-color var(--dur-fast) var(--ease-smooth);
}
.mobile-sidebar-close:hover {
transform: scale(1.05);
}
.mobile-sidebar-sheet .sidebar-block {
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.mobile-sidebar-sheet .tree-btn {
margin-bottom: 2px;
}
.mobile-sidebar-sheet .folder-row {
align-items: stretch;
gap: 4px;
}
.mobile-sidebar-sheet .folder-row .tree-btn {
min-height: 42px;
}
.mobile-sidebar-sheet .sidebar-title,
.mobile-sidebar-sheet .sidebar-title-row {
padding-bottom: 6px;
margin-bottom: 0;
}
.mobile-sidebar-sheet .tree-btn {
padding-left: 8px;
padding-right: 8px;
border-radius: 10px;
}
.mobile-sidebar-sheet .tree-btn.active {
background: #eef4ff;
}
.mobile-sidebar-sheet .folder-delete-btn {
width: 28px;
height: 42px;
border-radius: 8px;
}
.list-col {
max-width: none;
}
.list-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 8px;
align-items: center;
}
.list-count {
grid-column: auto;
width: auto;
font-size: 12px;
white-space: nowrap;
}
.list-head .search-input-wrap {
width: 100%;
min-width: 0;
}
.list-head .search-input {
width: 100%;
min-width: 0;
height: 42px;
border-radius: 14px;
}
.list-icon-btn {
width: auto;
min-width: 0;
padding: 0 12px;
font-size: 13px;
gap: 6px;
white-space: nowrap;
}
.toolbar.actions {
justify-content: flex-end;
flex-wrap: unset;
gap: var(--actions-gap);
overflow: visible;
padding-bottom: 2px;
}
.actions {
gap: var(--actions-gap);
}
.toolbar.actions .btn.small {
width: auto;
min-width: 0;
height: 34px;
padding: 0 12px;
font-size: 13px;
gap: 6px;
border-radius: 999px;
white-space: nowrap;
}
.mobile-fab-wrap {
position: fixed;
right: 14px;
bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
z-index: 45;
}
.mobile-fab-trigger {
width: 36px;
height: 56px;
padding: 0;
border-radius: 999px;
font-size: 0;
gap: 0;
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
}
.mobile-fab-trigger .btn-icon {
margin: 0;
width: 20px;
height: 20px;
}
.mobile-fab-wrap .create-menu {
left: auto;
right: 0;
top: auto;
bottom: calc(100% + 10px);
}
.list-panel {
border-radius: 16px;
overflow: visible;
}
.list-item {
padding: 12px;
border-radius: 14px;
}
.row-check {
width: 18px;
height: 18px;
}
.vault-grid.mobile-panel-detail .sidebar,
.vault-grid.mobile-panel-detail .list-col,
.vault-grid.mobile-panel-edit .sidebar,
.vault-grid.mobile-panel-edit .list-col {
display: none;
}
.mobile-detail-sheet {
display: block;
position: fixed;
left: 0;
right: 0;
top: calc(var(--mobile-topbar-height) + env(safe-area-inset-top));
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
z-index: 35;
overflow: auto;
background: transparent;
padding: 0 0 18px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate3d(0, 18px, 0);
transition:
opacity 220ms var(--ease-smooth),
transform 260ms var(--ease-out-soft),
visibility 220ms var(--ease-smooth);
}
.mobile-detail-sheet.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate3d(0, 0, 0);
}
.mobile-panel-head {
display: flex;
align-items: center;
margin: 0 10px 10px;
}
.mobile-panel-back {
min-height: 40px;
}
.mobile-detail-sheet > .detail-switch-stage,
.mobile-detail-sheet > .card,
.mobile-detail-sheet > .empty {
margin-left: 10px;
margin-right: 10px;
}
.detail-col .card,
.import-export-panel,
.settings-subcard {
border-radius: 16px;
}
.card {
padding: 14px 14px;
}
.section-head {
align-items: flex-start;
gap: 10px;
flex-direction: column;
}
.detail-actions {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.detail-actions .actions {
width: 100%;
}
.detail-actions .actions .btn,
.detail-delete-btn {
width: 100%;
}
.kv-row {
grid-template-columns: minmax(64px, 80px) minmax(0, 1fr) auto;
align-items: center;
}
.kv-line {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.kv-actions {
width: auto;
justify-content: flex-end;
flex-wrap: nowrap;
}
.kv-actions .btn.small {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
font-size: 0;
gap: 0;
border-radius: 999px;
}
.kv-actions .btn.small .btn-icon {
margin: 0;
}
.import-export-panels,
.settings-twofactor-grid {
gap: 10px;
}
.import-export-panel .actions .btn,
.settings-subcard .actions .btn,
.section-head .actions .btn {
width: 100%;
}
.totp-grid {
gap: 10px;
}
.totp-qr {
min-height: 180px;
}
.totp-qr svg,
.totp-qr img {
width: 160px;
height: 160px;
}
.invite-toolbar {
align-items: stretch;
}
.mobile-settings-card {
min-height: calc(100dvh - 170px);
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-settings-subhead {
display: flex;
align-items: center;
}
.mobile-settings-back {
min-height: 38px;
}
.mobile-settings-links {
display: grid;
gap: 8px;
align-content: start;
}
.mobile-settings-link {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 0 12px;
border: 1px solid #dbe2ec;
border-radius: 14px;
background: #f8fafc;
color: #0f172a;
text-decoration: none;
font-weight: 700;
}
.mobile-settings-link.active {
background: #e8f0ff;
border-color: #b9cff6;
color: #175ddc;
}
.mobile-settings-logout {
width: 100%;
margin-top: auto;
}
.stack,
.import-export-page,
.totp-codes-page,
.detail-col {
min-height: auto;
}
.invite-create-group {
align-items: stretch;
width: 100%;
}
.input.small {
width: 100%;
}
.table,
.table tbody,
.table tr,
.table td {
display: block;
width: 100%;
}
.table thead {
display: none;
}
.table tr {
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
padding: 10px 12px;
margin-bottom: 10px;
}
.table td {
border-bottom: 1px solid #edf1f6;
padding: 10px 0;
}
.table td:last-child {
border-bottom: none;
padding-bottom: 0;
}
.table td::before {
display: block;
content: attr(data-label);
margin-bottom: 4px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.dialog-mask {
align-items: center;
justify-items: center;
padding: 16px;
}
.dialog-card {
width: 90%;
max-width: 460px;
max-height: calc(100dvh - 10px);
overflow: auto;
border-radius: 22px;
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
}
.dialog-card.warning {
max-width: 520px;
}
.dialog-warning-strip {
margin: -18px -16px 16px;
}
.dialog-title {
font-size: 24px;
}
.dialog-btn {
height: 46px;
font-size: 16px;
}
.toast-stack {
top: 10px;
left: 10px;
right: 10px;
width: auto;
}
}
@media (max-width: 900px) {
.backup-grid {
grid-template-columns: 1fr;
}
.backup-operations-sidebar,
.backup-destination-sidebar {
position: static;
}
}
@media (max-width: 640px) {
.backup-interval-row {
grid-template-columns: 1fr;
}
.backup-browser-row,
.field-grid {
grid-template-columns: 1fr;
}
.backup-destination-top {
align-items: flex-start;
flex-direction: column;
}
.backup-add-chooser {
flex-direction: column;
}
.backup-name-row {
grid-template-columns: 1fr;
}
.backup-option-field {
align-items: flex-start;
}
.backup-help-bubble {
left: 0;
transform: translate(0, -4px);
}
.backup-help-bubble::before {
left: 16px;
transform: rotate(45deg);
}
.backup-help-wrap:hover .backup-help-bubble,
.backup-help-wrap:focus-within .backup-help-bubble,
.backup-help-wrap.open .backup-help-bubble {
transform: translate(0, 0);
}
}
+321
View File
@@ -0,0 +1,321 @@
.app-page {
min-height: 100%;
padding: 20px;
position: relative;
background: transparent;
}
.app-shell {
height: calc(100vh - 40px);
max-width: 1600px;
margin: 0 auto;
position: relative;
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
animation: shell-enter 560ms var(--ease-out-strong) both;
}
.topbar {
height: 58px;
border-bottom: 1px solid var(--line-soft);
color: #0f172a;
background: rgba(244, 248, 255, 0.72);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
transition: background-color var(--dur-fast) var(--ease-smooth), border-color var(--dur-fast) var(--ease-smooth);
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 34px;
font-weight: 800;
color: var(--text);
}
.brand-wordmark {
display: block;
height: auto;
width: clamp(210px, 20vw, 290px);
max-width: 100%;
filter: drop-shadow(0 12px 24px rgba(43, 102, 217, 0.12));
}
.mobile-page-title {
display: none;
min-width: 0;
max-width: min(58vw, 240px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 19px;
line-height: 1.2;
font-weight: 800;
color: #0f172a;
}
.brand-logo {
width: 42px;
height: 42px;
object-fit: contain;
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.22));
transition: transform var(--dur-medium) var(--ease-out-soft), filter var(--dur-medium) var(--ease-out-soft);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.mobile-tabbar {
display: none;
}
.mobile-sidebar-toggle {
display: none;
}
.mobile-lock-btn {
display: none;
}
.mobile-theme-btn {
display: none;
}
.theme-switch-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
}
.theme-switch {
position: relative;
display: inline-block;
width: 56px;
height: 32px;
}
.theme-switch-input {
opacity: 0;
width: 0;
height: 0;
}
.theme-switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, #dceaff, #c8dcff);
border: 1px solid #9dbbec;
transition:
background var(--dur-medium) var(--ease-out-soft),
border-color var(--dur-medium) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-out-soft),
transform var(--dur-fast) var(--ease-out-soft);
border-radius: 999px;
}
.theme-switch-slider::before {
position: absolute;
content: '';
height: 26px;
width: 26px;
border-radius: 999px;
left: 2px;
bottom: 2px;
z-index: 2;
background: linear-gradient(180deg, #ffffff, #edf4ff);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14);
transition:
transform var(--dur-medium) var(--ease-out-strong),
box-shadow var(--dur-fast) var(--ease-out-soft),
background var(--dur-fast) var(--ease-smooth);
}
.theme-switch .sun svg {
position: absolute;
top: 6px;
left: 32px;
z-index: 1;
width: 18px;
height: 18px;
opacity: 0.95;
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.theme-switch .moon svg {
fill: #5b86d6;
position: absolute;
top: 7px;
left: 7px;
z-index: 1;
width: 16px;
height: 16px;
opacity: 0.88;
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.theme-switch-input:checked + .theme-switch-slider {
background: linear-gradient(180deg, #173150, #122742);
border-color: #35527a;
}
.theme-switch-input:focus + .theme-switch-slider {
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.22);
}
.theme-switch-input:checked + .theme-switch-slider::before {
transform: translateX(24px);
}
.theme-switch:hover .theme-switch-slider {
transform: scale(1.02);
}
.theme-switch:hover .sun svg,
.theme-switch:hover .moon svg {
transform: scale(1.08);
}
.topbar-actions .btn {
height: 34px;
border-radius: 12px;
padding: 0 12px;
font-size: 13px;
font-weight: 600;
transform: translate3d(var(--mag-x), var(--mag-y), 0);
transition-duration: 220ms;
}
.topbar-actions .btn:hover:not(:disabled) {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 2px), 0) scale(1.02);
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
border-radius: 999px;
padding: 0 12px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: rgba(249, 251, 255, 0.92);
color: var(--muted-strong);
font-size: 14px;
font-weight: 600;
box-shadow: 0 10px 18px rgba(13, 31, 68, 0.05);
transform: translate3d(var(--mag-x), var(--mag-y), 0);
transition:
transform 220ms var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft),
border-color var(--dur-fast) var(--ease-smooth),
background-color var(--dur-fast) var(--ease-smooth);
}
.user-chip:hover {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08);
}
.app-main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 200px 1fr;
}
.app-side {
border-right: 1px solid var(--line-soft);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.side-link {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 12px;
border-radius: 14px;
color: var(--muted-strong);
text-decoration: none;
border: 1px solid transparent;
font-weight: 600;
font-size: 14px;
transition:
background-color var(--dur-fast) var(--ease-smooth),
border-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft);
}
.side-link:hover {
background: #ffffff;
border-color: rgba(128, 152, 192, 0.18);
color: var(--text);
transform: translate3d(calc(var(--mag-x) + 3px), var(--mag-y), 0);
box-shadow: 0 14px 24px rgba(15, 23, 42, 0.05);
}
.side-link.active {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(59, 130, 246, 0.08));
border-color: rgba(37, 99, 235, 0.28);
color: var(--primary-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.64), 0 10px 18px rgba(37, 99, 235, 0.1);
}
.content {
min-height: 0;
padding: 14px;
overflow: hidden;
}
.route-stage {
height: 100%;
min-height: 0;
overflow: auto;
}
@media (min-width: 901px) {
.route-stage {
animation: route-stage-in 240ms var(--ease-out-soft) both;
}
}
.mobile-sidebar-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.36);
z-index: 54;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 220ms var(--ease-smooth),
visibility 220ms var(--ease-smooth);
}
.mobile-sidebar-mask.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.mobile-sidebar-head {
display: none;
}
+46
View File
@@ -0,0 +1,46 @@
:root {
--bg-accent: #e7edf8;
--panel: #f9fbff;
--panel-soft: #f2f6fd;
--panel-muted: #e8eff9;
--line: rgba(128, 152, 192, 0.32);
--line-soft: rgba(143, 167, 206, 0.18);
--text: #0b1730;
--muted: #60708b;
--muted-strong: #334765;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-strong: #0f3f98;
--danger: #d92d57;
--overlay-strong: rgba(15, 23, 42, 0.56);
--shadow-sm: 0 10px 22px rgba(13, 31, 68, 0.045);
--shadow-md: 0 22px 48px rgba(13, 31, 68, 0.08);
--shadow-lg: 0 28px 76px rgba(13, 31, 68, 0.11);
--ease-out-strong: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1);
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--dur-fast: 180ms;
--dur-medium: 240ms;
--dur-panel: 280ms;
--actions-gap: clamp(0px, calc((100vw - 520px) * 1), 10px);
}
:root[data-theme='dark'] {
--bg-accent: #06111d;
--panel: #0d192b;
--panel-soft: #112136;
--panel-muted: #0a1626;
--line: rgba(108, 141, 190, 0.28);
--line-soft: rgba(120, 152, 198, 0.16);
--text: #edf4ff;
--muted: #8fa6c6;
--muted-strong: #c3d5ef;
--primary: #84b6ff;
--primary-hover: #a6ccff;
--primary-strong: #f3f8ff;
--danger: #ff8ba8;
--overlay-strong: rgba(2, 8, 20, 0.84);
--shadow-sm: 0 14px 28px rgba(1, 7, 18, 0.24);
--shadow-md: 0 24px 52px rgba(1, 7, 18, 0.36);
--shadow-lg: 0 34px 88px rgba(1, 7, 18, 0.46);
}
File diff suppressed because it is too large Load Diff