29 Commits

Author SHA1 Message Date
shuaiplus 15ee922777 chore: update version to 1.4.6 in package.json, package-lock.json, and app-version.ts 2026-04-25 16:05:07 +08:00
shuaiplus 2ea0b2c14c feat: Adds an API to update attachment metadata, supporting the repair of encrypted information of old attachments 2026-04-25 15:52:00 +08:00
shuaiplus 4ec1926888 fix: correct dialog-card width from 5000px to 500px for proper layout 2026-04-25 12:07:45 +08:00
shuaiplus 3995e01336 feat: enhance icon error handling and loading state management in TotpCodesPage and VaultListIcon components 2026-04-25 10:20:30 +08:00
shuaiplus 481536ba24 feat: update list icon opacity and z-index for improved loading behavior 2026-04-25 04:40:22 +08:00
shuaiplus db8b9263a1 feat: implement session timeout feature with customizable actions and update UI components 2026-04-25 03:49:15 +08:00
shuaiplus a1f7250e90 feat: update mobile layout query to 1180px and enhance icon loading experience 2026-04-25 03:19:06 +08:00
shuaiplus e4bc1b9bbe Refactor frontend styles toward Tailwind utilities and unified design system 2026-04-25 02:23:10 +08:00
shuaiplus 514889adfc feat: refactor TOTP code handling to improve state management and refresh logic 2026-04-25 01:48:20 +08:00
shuaiplus fccc85c4bb feat: enhance ConfirmDialog with focus management and accessibility improvements 2026-04-25 01:36:12 +08:00
shuaiplus acd59a7387 feat: add auto-lock feature with customizable timeout settings and update UI for security preferences 2026-04-24 15:27:46 +08:00
shuaiplus d40b0514fd Refactor styles to utilize Tailwind CSS utility classes for improved consistency and maintainability across forms, motion, shell, and vault components. Remove deprecated reduced-motion styles and consolidate motion-related animations. Update color tokens for better contrast and accessibility. Introduce a new Tailwind CSS configuration file. 2026-04-24 15:14:12 +08:00
shuaiplus 033d44808f chore: update version to 1.4.5 in package.json, package-lock.json, and app-version.ts 2026-04-24 00:51:27 +08:00
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
71 changed files with 6814 additions and 5359 deletions
+1
View File
@@ -42,3 +42,4 @@ tmp/
.tmp/ .tmp/
nodewarden.wiki/ nodewarden.wiki/
AGENTS.md
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,
+951 -15
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.3", "version": "1.4.6",
"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",
@@ -40,6 +40,9 @@
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.3'; export const APP_VERSION = '1.4.6';
+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;
}
+58
View File
@@ -279,6 +279,64 @@ export async function handleGetAttachment(
}); });
} }
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
export async function handleUpdateAttachmentMetadata(
request: Request,
env: Env,
userId: string,
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
const attachment = await storage.getAttachment(attachmentId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
let body: { fileName?: string | null; key?: string | null };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) {
return errorResponse('No metadata fields supplied', 400);
}
if (Object.prototype.hasOwnProperty.call(body, 'fileName')) {
const fileName = String(body.fileName || '').trim();
if (!fileName) return errorResponse('fileName is required', 400);
attachment.fileName = fileName;
}
if (Object.prototype.hasOwnProperty.call(body, 'key')) {
const key = body.key == null ? null : String(body.key || '').trim();
attachment.key = key || null;
}
await storage.saveAttachment(attachment);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return jsonResponse({
object: 'attachment',
id: attachment.id,
fileName: attachment.fileName,
key: attachment.key,
size: String(Number(attachment.size) || 0),
sizeName: attachment.sizeName,
});
}
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx // GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
// Public download endpoint (uses token for auth instead of header) // Public download endpoint (uses token for auth instead of header)
export async function handlePublicDownloadAttachment( export async function handlePublicDownloadAttachment(
+2
View File
@@ -218,6 +218,7 @@ 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);
if (destination.includeAttachments) {
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession); const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
let attachmentIndexChanged = false; let attachmentIndexChanged = false;
for (const attachment of archive.manifest.attachmentBlobs || []) { for (const attachment of archive.manifest.attachmentBlobs || []) {
@@ -239,6 +240,7 @@ async function executeConfiguredBackup(
if (attachmentIndexChanged) { if (attachmentIndexChanged) {
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex); 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++) {
await progress?.({ await progress?.({
+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,
+16
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,
@@ -58,6 +60,7 @@ import {
handleCreateAttachment, handleCreateAttachment,
handleUploadAttachment, handleUploadAttachment,
handleGetAttachment, handleGetAttachment,
handleUpdateAttachmentMetadata,
handleDeleteAttachment, handleDeleteAttachment,
} from './handlers/attachments'; } from './handlers/attachments';
import { handleAuthenticatedDeviceRoute } from './router-devices'; import { handleAuthenticatedDeviceRoute } from './router-devices';
@@ -119,6 +122,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);
} }
@@ -191,6 +202,11 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId); if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
} }
const attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i);
if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) {
return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]);
}
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i); const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
if (attachmentDeleteMatch && method === 'POST') { if (attachmentDeleteMatch && method === 'POST') {
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]); return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
+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]);
+25 -9
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,
@@ -116,7 +126,12 @@ function buildConfigResponse(origin: string) {
} }
function normalizeIconHost(rawHost: string): string | null { function normalizeIconHost(rawHost: string): string | null {
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, ''); let decoded: string;
try {
decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
} catch {
return null;
}
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null; if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
try { try {
const parsed = new URL(`https://${decoded}`); const parsed = new URL(`https://${decoded}`);
@@ -126,9 +141,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 +186,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 +235,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;
+33
View File
@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
canvas: 'var(--bg-accent)',
panel: 'var(--panel)',
'panel-soft': 'var(--panel-soft)',
'panel-muted': 'var(--panel-muted)',
line: 'var(--line)',
'line-soft': 'var(--line-soft)',
ink: 'var(--text)',
muted: 'var(--muted)',
'muted-strong': 'var(--muted-strong)',
brand: 'var(--primary)',
'brand-hover': 'var(--primary-hover)',
'brand-strong': 'var(--primary-strong)',
danger: 'var(--danger)',
},
boxShadow: {
soft: 'var(--shadow-sm)',
panel: 'var(--shadow-md)',
elevated: 'var(--shadow-lg)',
},
fontFamily: {
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
},
},
},
plugins: [],
};
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

+227 -66
View File
@@ -18,16 +18,18 @@ import {
revokeCurrentSession, revokeCurrentSession,
getTotpStatus, getTotpStatus,
saveSession, saveSession,
stripProfileSecrets,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
import { buildSendShareKey, getSends } from '@/lib/api/send'; import { buildSendShareKey, getSends } from '@/lib/api/send';
import { import {
getCiphers, getCiphers,
getFolders, getFolders,
repairCipherAttachmentMetadata,
updateFolder, updateFolder,
} from '@/lib/api/vault'; } from '@/lib/api/vault';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto'; import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto';
import { import {
buildPublicSendUrl, buildPublicSendUrl,
deriveSendKeyParts, deriveSendKeyParts,
@@ -82,48 +84,12 @@ const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
type ThemePreference = 'system' | 'light' | 'dark'; type ThemePreference = 'system' | 'light' | 'dark';
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab'; type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
type SessionTimeoutAction = 'lock' | 'logout';
function installMagneticUiFeedback() { const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}; const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}; const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
if (typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches) return () => {};
const resetNode = (node: HTMLElement) => {
node.style.setProperty('--mag-x', '0px');
node.style.setProperty('--mag-y', '0px');
node.style.removeProperty('--mx');
node.style.removeProperty('--my');
};
const onPointerMove = (event: PointerEvent) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
if (!node) return;
const rect = node.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
const dx = (localX - rect.width / 2) / Math.max(rect.width / 2, 1);
const dy = (localY - rect.height / 2) / Math.max(rect.height / 2, 1);
node.style.setProperty('--mx', `${localX}px`);
node.style.setProperty('--my', `${localY}px`);
node.style.setProperty('--mag-x', `${dx * 6}px`);
node.style.setProperty('--mag-y', `${dy * 4}px`);
};
const onPointerLeave = (event: Event) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
if (!node) return;
resetNode(node);
};
document.addEventListener('pointermove', onPointerMove, { passive: true });
document.addEventListener('pointerleave', onPointerLeave, true);
return () => {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerleave', onPointerLeave, true);
};
}
function readThemePreference(): ThemePreference { function readThemePreference(): ThemePreference {
if (typeof window === 'undefined') return 'system'; if (typeof window === 'undefined') return 'system';
@@ -137,6 +103,18 @@ function resolveSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} }
function readLockTimeoutMinutes(): LockTimeoutMinutes {
if (typeof window === 'undefined') return 15;
const value = Number(window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY));
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
}
function readSessionTimeoutAction(): SessionTimeoutAction {
if (typeof window === 'undefined') return 'lock';
const value = String(window.localStorage.getItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY) || '').trim();
return value === 'logout' ? 'logout' : 'lock';
}
export default function App() { export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
@@ -170,6 +148,7 @@ export default function App() {
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [unlockPassword, setUnlockPassword] = useState(''); const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null); const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(true); const [rememberDevice, setRememberDevice] = useState(true);
const [totpSubmitting, setTotpSubmitting] = useState(false); const [totpSubmitting, setTotpSubmitting] = useState(false);
@@ -180,10 +159,13 @@ export default function App() {
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key); const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null); const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false); const [mobileLayout, setMobileLayout] = useState(false);
const [mobileSidebarToggleKey, setMobileSidebarToggleKey] = useState(0);
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]); const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]); const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]); const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
@@ -245,7 +227,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia('(max-width: 900px)'); const media = window.matchMedia('(max-width: 1180px)');
const sync = () => setMobileLayout(media.matches); const sync = () => setMobileLayout(media.matches);
sync(); sync();
if (typeof media.addEventListener === 'function') { if (typeof media.addEventListener === 'function') {
@@ -287,12 +269,20 @@ export default function App() {
}, [profile]); }, [profile]);
useEffect(() => { useEffect(() => {
if (phase === 'locked' && profile?.key && session) { if (phase === 'locked' && session?.email) {
setUnlockPreparing(false); setUnlockPreparing(false);
} }
}, [phase, profile, session]); }, [phase, profile, session]);
useEffect(() => installMagneticUiFeedback(), []); useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
}, [lockTimeoutMinutes]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY, sessionTimeoutAction);
}, [sessionTimeoutAction]);
function handleToggleTheme() { function handleToggleTheme() {
setThemePreference((prev) => { setThemePreference((prev) => {
@@ -307,6 +297,16 @@ export default function App() {
saveSession(next); saveSession(next);
} }
function setLockTimeoutMinutes(next: LockTimeoutMinutes) {
setLockTimeoutMinutesState(next);
pushToast('success', t('txt_session_timeout_updated'));
}
function setSessionTimeoutAction(next: SessionTimeoutAction) {
setSessionTimeoutActionState(next);
pushToast('success', t('txt_session_timeout_updated'));
}
const authedFetch = useMemo( const authedFetch = useMemo(
() => () =>
createAuthedFetch( createAuthedFetch(
@@ -353,7 +353,7 @@ export default function App() {
setSession(boot.session); setSession(boot.session);
setProfile(boot.profile); setProfile(boot.profile);
setPhase(boot.phase); setPhase(boot.phase);
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key); setUnlockPreparing(boot.phase === 'locked' && !boot.session?.email);
})(); })();
return () => { return () => {
@@ -377,7 +377,7 @@ export default function App() {
} }
setSession(result.session); setSession(result.session);
if (result.profile) { if (result.profile) {
setProfile(result.profile); setProfile(stripProfileSecrets(result.profile));
} }
})(); })();
return () => { return () => {
@@ -385,17 +385,19 @@ export default function App() {
}; };
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) {
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setUnlockPassword('');
setPhase('app'); setPhase('app');
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
navigate('/vault'); navigate('/vault');
} }
pushToast('success', t('txt_login_success')); pushToast('success', successMessage);
void (async () => { void (async () => {
try { try {
const hydratedProfile = await login.profilePromise; const hydratedProfile = await login.profilePromise;
@@ -422,6 +424,7 @@ export default function App() {
} }
if (result.kind === 'totp') { if (result.kind === 'totp') {
setPendingTotp(result.pendingTotp); setPendingTotp(result.pendingTotp);
setPendingTotpMode('login');
setTotpCode(''); setTotpCode('');
setRememberDevice(true); setRememberDevice(true);
return; return;
@@ -444,7 +447,7 @@ export default function App() {
setTotpSubmitting(true); setTotpSubmitting(true);
try { try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
await finalizeLogin(login); await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success'));
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
} finally { } finally {
@@ -567,20 +570,26 @@ export default function App() {
async function handleUnlock() { async function handleUnlock() {
if (pendingAuthAction) return; if (pendingAuthAction) return;
if (!session || !profile) return; if (!session?.email) return;
if (!unlockPassword) { if (!unlockPassword) {
pushToast('error', t('txt_please_input_master_password')); pushToast('error', t('txt_please_input_master_password'));
return; return;
} }
setPendingAuthAction('unlock'); setPendingAuthAction('unlock');
try { try {
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
setSession(nextSession); if (result.kind === 'success') {
setUnlockPassword(''); await finalizeLogin(result.login, t('txt_unlocked'));
setUnlockPreparing(false); return;
setPhase('app'); }
if (location === '/' || location === '/lock') navigate('/vault'); if (result.kind === 'totp') {
pushToast('success', t('txt_unlocked')); setPendingTotp(result.pendingTotp);
setPendingTotpMode('unlock');
setTotpCode('');
setRememberDevice(true);
return;
}
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
} catch { } catch {
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect')); pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
} finally { } finally {
@@ -588,17 +597,30 @@ export default function App() {
} }
} }
function handleLock() { function lockCurrentSession() {
if (!session) return; const currentSession = sessionRef.current;
const nextSession = { ...session }; if (!currentSession) return;
const nextSession = { ...currentSession };
delete nextSession.symEncKey; delete nextSession.symEncKey;
delete nextSession.symMacKey; delete nextSession.symMacKey;
setSession(nextSession); setSession(nextSession);
setProfile((prev) => stripProfileSecrets(prev));
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
setUnlockPassword('');
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setUnlockPreparing(false); setUnlockPreparing(false);
setPhase('locked'); setPhase('locked');
navigate('/lock'); navigate('/lock');
} }
function handleLock() {
lockCurrentSession();
}
function logoutNow() { function logoutNow() {
void revokeCurrentSession(sessionRef.current); void revokeCurrentSession(sessionRef.current);
setConfirm(null); setConfirm(null);
@@ -607,6 +629,7 @@ export default function App() {
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null);
setPhase('login'); setPhase('login');
navigate('/login'); navigate('/login');
} }
@@ -622,6 +645,62 @@ export default function App() {
}); });
} }
useEffect(() => {
if (phase !== 'app' || lockTimeoutMinutes === 0) return;
if (typeof window === 'undefined') return;
let timerId: number | null = null;
let lastActivityAt = 0;
const timeoutMs = lockTimeoutMinutes * 60 * 1000;
const clearTimer = () => {
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
};
const runTimeoutAction = () => {
if (sessionTimeoutAction === 'logout') {
logoutNow();
return;
}
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) {
lockCurrentSession();
}
};
const scheduleTimeout = () => {
clearTimer();
timerId = window.setTimeout(() => {
runTimeoutAction();
}, timeoutMs);
};
const markActivity = () => {
const now = Date.now();
if (now - lastActivityAt < 1000) return;
lastActivityAt = now;
scheduleTimeout();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') markActivity();
};
scheduleTimeout();
window.addEventListener('pointerdown', markActivity, { passive: true });
window.addEventListener('keydown', markActivity);
window.addEventListener('scroll', markActivity, { passive: true });
window.addEventListener('touchstart', markActivity, { passive: true });
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearTimer();
window.removeEventListener('pointerdown', markActivity);
window.removeEventListener('keydown', markActivity);
window.removeEventListener('scroll', markActivity);
window.removeEventListener('touchstart', markActivity);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [phase, lockTimeoutMinutes, sessionTimeoutAction]);
function renderPassiveOverlays() { function renderPassiveOverlays() {
return ( return (
<AppGlobalOverlays <AppGlobalOverlays
@@ -725,6 +804,34 @@ export default function App() {
return value; return value;
} }
}; };
const sameBytes = (a: Uint8Array, b: Uint8Array) => {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
};
const decryptFieldWithSource = async (
value: string | null | undefined,
itemEnc: Uint8Array,
itemMac: Uint8Array
): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => {
const raw = String(value || '').trim();
if (!raw) return { text: '', source: 'plain' };
try {
return { text: await decryptStr(raw, itemEnc, itemMac), source: 'item' };
} catch {
// 继续尝试旧 user key 数据。
}
if (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) {
try {
return { text: await decryptStr(raw, encKey, macKey), source: 'user' };
} catch {
// 保留原文。
}
}
return { text: raw, source: 'plain' };
};
const folders = await Promise.all( const folders = await Promise.all(
foldersQuery.data.map(async (folder) => ({ foldersQuery.data.map(async (folder) => ({
@@ -766,6 +873,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,
@@ -822,10 +937,45 @@ export default function App() {
} }
if (Array.isArray(cipher.attachments)) { if (Array.isArray(cipher.attachments)) {
nextCipher.attachments = await Promise.all( nextCipher.attachments = await Promise.all(
cipher.attachments.map(async (attachment) => ({ cipher.attachments.map(async (attachment) => {
const attachmentId = String(attachment?.id || '').trim();
const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac);
const metadata: { fileName?: string; key?: string | null } = {};
if (attachmentId && fileNameResult.source === 'user') {
metadata.fileName = await encryptBw(new TextEncoder().encode(fileNameResult.text), itemEnc, itemMac);
}
const attachmentKey = String(attachment?.key || '').trim();
if (
attachmentId &&
attachmentKey &&
looksLikeCipherString(attachmentKey) &&
(!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey))
) {
try {
await decryptBw(attachmentKey, itemEnc, itemMac);
} catch {
try {
const rawAttachmentKey = await decryptBw(attachmentKey, encKey, macKey);
if (rawAttachmentKey.length >= 64) {
metadata.key = await encryptBw(rawAttachmentKey, itemEnc, itemMac);
}
} catch {
// 文件下载时会继续尝试旧格式。
}
}
}
if (attachmentId && Object.keys(metadata).length > 0) {
void repairCipherAttachmentMetadata(authedFetch, cipher.id, attachmentId, metadata);
}
return {
...attachment, ...attachment,
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac), decFileName: fileNameResult.text,
})) };
})
); );
} }
return nextCipher; return nextCipher;
@@ -1139,6 +1289,7 @@ export default function App() {
profile, profile,
session, session,
mobileLayout, mobileLayout,
mobileSidebarToggleKey,
importRoute: IMPORT_ROUTE, importRoute: IMPORT_ROUTE,
settingsHomeRoute: SETTINGS_HOME_ROUTE, settingsHomeRoute: SETTINGS_HOME_ROUTE,
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE, settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
@@ -1151,6 +1302,8 @@ export default function App() {
users: usersQuery.data || [], users: usersQuery.data || [],
invites: invitesQuery.data || [], invites: invitesQuery.data || [],
totpEnabled: !!totpStatusQuery.data?.enabled, totpEnabled: !!totpStatusQuery.data?.enabled,
lockTimeoutMinutes,
sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevices: authorizedDevicesQuery.data || [],
authorizedDevicesLoading: authorizedDevicesQuery.isFetching, authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
onNavigate: navigate, onNavigate: navigate,
@@ -1195,7 +1348,12 @@ export default function App() {
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction,
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,
@@ -1256,7 +1414,7 @@ export default function App() {
<AuthViews <AuthViews
mode={phase} mode={phase}
pendingAction={pendingAuthAction} pendingAction={pendingAuthAction}
unlockReady={!!profile?.key && !!session} unlockReady={!!session?.email}
unlockPreparing={unlockPreparing} unlockPreparing={unlockPreparing}
loginValues={loginValues} loginValues={loginValues}
registerValues={registerValues} registerValues={registerValues}
@@ -1298,12 +1456,14 @@ export default function App() {
onCancelTotp={() => { onCancelTotp={() => {
if (totpSubmitting) return; if (totpSubmitting) return;
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setRememberDevice(true); setRememberDevice(true);
}} }}
onUseRecoveryCode={() => { onUseRecoveryCode={() => {
if (totpSubmitting) return; if (totpSubmitting) return;
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setRememberDevice(true); setRememberDevice(true);
navigate('/recover-2fa'); navigate('/recover-2fa');
@@ -1337,6 +1497,7 @@ export default function App() {
onLock={handleLock} onLock={handleLock}
onLogout={handleLogout} onLogout={handleLogout}
onToggleTheme={handleToggleTheme} onToggleTheme={handleToggleTheme}
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
mainRoutesProps={mainRoutesProps} mainRoutesProps={mainRoutesProps}
/> />
@@ -21,6 +21,7 @@ interface AppAuthenticatedShellProps {
onLock: () => void; onLock: () => void;
onLogout: () => void; onLogout: () => void;
onToggleTheme: () => void; onToggleTheme: () => void;
onToggleMobileSidebar: () => void;
mainRoutesProps: AppMainRoutesProps; mainRoutesProps: AppMainRoutesProps;
} }
@@ -33,7 +34,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">
@@ -51,7 +52,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
className="btn btn-secondary small mobile-sidebar-toggle" className="btn btn-secondary small mobile-sidebar-toggle"
aria-label={props.sidebarToggleTitle} aria-label={props.sidebarToggleTitle}
title={props.sidebarToggleTitle} title={props.sidebarToggleTitle}
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))} onClick={props.onToggleMobileSidebar}
> >
<FolderIcon size={16} className="btn-icon" /> <FolderIcon size={16} className="btn-icon" />
</button> </button>
+1 -1
View File
@@ -76,7 +76,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<span>{t('txt_totp_code')}</span> <span>{t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} /> <input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label> </label>
<label className="check-line" style={{ marginBottom: 0 }}> <label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} /> <input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span> <span>{t('txt_trust_this_device_for_30_days')}</span>
</label> </label>
+17
View File
@@ -33,6 +33,7 @@ export interface AppMainRoutesProps {
profile: Profile | null; profile: Profile | null;
session: SessionState | null; session: SessionState | null;
mobileLayout: boolean; mobileLayout: boolean;
mobileSidebarToggleKey: number;
importRoute: string; importRoute: string;
settingsHomeRoute: string; settingsHomeRoute: string;
settingsAccountRoute: string; settingsAccountRoute: string;
@@ -45,6 +46,8 @@ export interface AppMainRoutesProps {
users: AdminUser[]; users: AdminUser[];
invites: AdminInvite[]; invites: AdminInvite[];
totpEnabled: boolean; totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
authorizedDevicesLoading: boolean; authorizedDevicesLoading: boolean;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
@@ -94,7 +97,12 @@ 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>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
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;
@@ -162,6 +170,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onBulkDelete={props.onBulkDeleteSends} onBulkDelete={props.onBulkDeleteSends}
uploadingSendFileName={props.uploadingSendFileName} uploadingSendFileName={props.uploadingSendFileName}
sendUploadPercent={props.sendUploadPercent} sendUploadPercent={props.sendUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
onNotify={props.onNotify} onNotify={props.onNotify}
/> />
</Suspense> </Suspense>
@@ -201,6 +210,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
attachmentDownloadPercent={props.attachmentDownloadPercent} attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName} uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent} attachmentUploadPercent={props.attachmentUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/> />
</Suspense> </Suspense>
</Route> </Route>
@@ -219,11 +229,17 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsPage <SettingsPage
profile={props.profile} profile={props.profile}
totpEnabled={props.totpEnabled} totpEnabled={props.totpEnabled}
lockTimeoutMinutes={props.lockTimeoutMinutes}
sessionTimeoutAction={props.sessionTimeoutAction}
onChangePassword={props.onChangePassword} onChangePassword={props.onChangePassword}
onSavePasswordHint={props.onSavePasswordHint} onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp} onEnableTotp={props.onEnableTotp}
onOpenDisableTotp={props.onOpenDisableTotp} onOpenDisableTotp={props.onOpenDisableTotp}
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey}
onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
/> />
</Suspense> </Suspense>
@@ -281,6 +297,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}
+100 -5
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { TriangleAlert } from 'lucide-preact'; import { TriangleAlert } from 'lucide-preact';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -42,6 +42,24 @@ function decrementDialogBodyLock() {
body.dataset.dialogCount = String(nextCount); body.dataset.dialogCount = String(nextCount);
} }
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
let dialogIdCounter = 0;
function getFocusableElements(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
if (element.hasAttribute('disabled') || element.getAttribute('aria-hidden') === 'true') return false;
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
});
}
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) { export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
@@ -64,7 +82,12 @@ export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | nu
export default function ConfirmDialog(props: ConfirmDialogProps) { export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open); const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel; const cardRef = useRef<HTMLFormElement | null>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`;
const canDismiss = !props.cancelDisabled && !closing;
useEffect(() => { useEffect(() => {
if (props.open) { if (props.open) {
@@ -83,6 +106,72 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
useDialogLifecycle(present, canDismiss ? props.onCancel : null); useDialogLifecycle(present, canDismiss ? props.onCancel : null);
useEffect(() => {
if (!props.open || typeof document === 'undefined') return;
const activeElement = document.activeElement;
restoreFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null;
const frameId = window.requestAnimationFrame(() => {
const card = cardRef.current;
if (!card) return;
const focusable = getFocusableElements(card);
const firstField = focusable.find((element) => (
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement
));
const cancelButton = focusable.find((element) => element.dataset.dialogCancel === 'true');
const confirmButton = focusable.find((element) => element.dataset.dialogConfirm === 'true');
const target = firstField || (props.danger ? cancelButton : confirmButton) || cancelButton || focusable[0] || card;
target.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frameId);
}, [props.open, props.danger]);
useEffect(() => {
if (props.open || present || typeof document === 'undefined') return;
const target = restoreFocusRef.current;
restoreFocusRef.current = null;
if (!target || !document.contains(target)) return;
target.focus({ preventScroll: true });
}, [props.open, present]);
useEffect(() => {
return () => {
const target = restoreFocusRef.current;
if (!target || typeof document === 'undefined' || !document.contains(target)) return;
target.focus({ preventScroll: true });
};
}, []);
function handleDialogKeyDown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const card = cardRef.current;
if (!card) return;
const focusable = getFocusableElements(card);
if (focusable.length === 0) {
event.preventDefault();
card.focus({ preventScroll: true });
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey) {
if (activeElement === first || activeElement === card || !card.contains(activeElement)) {
event.preventDefault();
last.focus({ preventScroll: true });
}
return;
}
if (activeElement === last || activeElement === card || !card.contains(activeElement)) {
event.preventDefault();
first.focus({ preventScroll: true });
}
}
if (!present || typeof document === 'undefined') return null; if (!present || typeof document === 'undefined') return null;
return createPortal(( return createPortal((
<div <div
@@ -93,10 +182,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
}} }}
> >
<form <form
ref={cardRef}
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`} className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={props.title} aria-labelledby={titleId}
aria-describedby={messageId}
tabIndex={-1}
onKeyDown={handleDialogKeyDown}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (props.confirmDisabled || closing) return; if (props.confirmDisabled || closing) return;
@@ -114,13 +207,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
</div> </div>
</> </>
) : null} ) : null}
<h3 className="dialog-title">{props.title}</h3> <h3 id={titleId} className="dialog-title">{props.title}</h3>
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div> <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
{props.children} {props.children}
<button <button
type="submit" type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`} className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
disabled={props.confirmDisabled} disabled={props.confirmDisabled}
data-dialog-confirm="true"
> >
{props.confirmText || t('txt_yes')} {props.confirmText || t('txt_yes')}
</button> </button>
@@ -129,6 +223,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
type="button" type="button"
className="btn btn-secondary dialog-btn" className="btn btn-secondary dialog-btn"
disabled={props.cancelDisabled} disabled={props.cancelDisabled}
data-dialog-cancel="true"
onClick={() => { onClick={() => {
if (props.cancelDisabled) return; if (props.cancelDisabled) return;
props.onCancel(); props.onCancel();
+78 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact'; import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send'; import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { toBufferSource } from '@/lib/crypto'; import { toBufferSource } from '@/lib/crypto';
@@ -11,29 +11,95 @@ interface PublicSendPageProps {
keyPart: string | null; keyPart: string | null;
} }
interface PublicSendFileData {
id: string;
fileName?: string | null;
sizeName?: string | null;
}
interface PublicSendData {
id: string;
type: 0 | 1;
decName?: string | null;
decText?: string | null;
decFileName?: string | null;
expirationDate?: string | null;
file?: PublicSendFileData | null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function parsePublicSendData(value: unknown): PublicSendData | null {
const source = asRecord(value);
if (!source) return null;
const id = optionalString(source.id);
const rawType = Number(source.type);
if (!id || (rawType !== 0 && rawType !== 1)) return null;
const fileSource = asRecord(source.file);
const fileId = optionalString(fileSource?.id);
const file = fileSource && fileId
? {
id: fileId,
fileName: optionalString(fileSource.fileName),
sizeName: optionalString(fileSource.sizeName),
}
: null;
if (rawType === 1 && !file) return null;
return {
id,
type: rawType,
decName: optionalString(source.decName),
decText: optionalString(source.decText),
decFileName: optionalString(source.decFileName),
expirationDate: optionalString(source.expirationDate),
file,
};
}
export default function PublicSendPage(props: PublicSendPageProps) { export default function PublicSendPage(props: PublicSendPageProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [needPassword, setNeedPassword] = useState(false); const [needPassword, setNeedPassword] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [sendData, setSendData] = useState<any>(null); const [sendData, setSendData] = useState<PublicSendData | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null); const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
const loadRequestRef = useRef(0);
const loadAbortRef = useRef<AbortController | null>(null);
async function loadSend(pass?: string): Promise<void> { async function loadSend(pass?: string): Promise<void> {
loadAbortRef.current?.abort();
const controller = new AbortController();
const requestId = loadRequestRef.current + 1;
loadRequestRef.current = requestId;
loadAbortRef.current = controller;
setBusy(true); setBusy(true);
setError(''); setError('');
setLoading(true);
try { try {
const data = await accessPublicSend(props.accessId, props.keyPart, pass); const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal });
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
if (!props.keyPart) { if (!props.keyPart) {
setError(t('txt_this_link_is_missing_decryption_key')); setError(t('txt_this_link_is_missing_decryption_key'));
setSendData(null); setSendData(null);
return; return;
} }
const decrypted = await decryptPublicSend(data, props.keyPart); const decrypted = await decryptPublicSend(data, props.keyPart);
setSendData(decrypted); if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const parsed = parsePublicSendData(decrypted);
if (!parsed) throw new Error(t('txt_send_unavailable'));
setSendData(parsed);
setNeedPassword(false); setNeedPassword(false);
} catch (e) { } catch (e) {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const err = e as Error & { status?: number }; const err = e as Error & { status?: number };
if (err.status === 401) { if (err.status === 401) {
setNeedPassword(true); setNeedPassword(true);
@@ -43,6 +109,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
} }
setSendData(null); setSendData(null);
} finally { } finally {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
setBusy(false); setBusy(false);
setLoading(false); setLoading(false);
} }
@@ -86,6 +153,9 @@ export default function PublicSendPage(props: PublicSendPageProps) {
useEffect(() => { useEffect(() => {
void loadSend(); void loadSend();
return () => {
loadAbortRef.current?.abort();
};
}, [props.accessId, props.keyPart]); }, [props.accessId, props.keyPart]);
return ( return (
@@ -120,13 +190,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && sendData && ( {!loading && sendData && (
<> <>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2> <h2 className="public-send-title">{sendData.decName || t('txt_no_name')}</h2>
{sendData.type === 0 ? ( {sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}> <div className="card public-send-card">
<div className="notes">{sendData.decText || ''}</div> <div className="notes">{sendData.decText || ''}</div>
</div> </div>
) : ( ) : (
<div className="card" style={{ marginTop: '10px' }}> <div className="card public-send-card">
<div className="kv-line"> <div className="kv-line">
<span>{t('txt_file')}</span> <span>{t('txt_file')}</span>
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong> <strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
@@ -142,7 +212,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && !sendData && !needPassword && !error && ( {!loading && !sendData && !needPassword && !error && (
<p className="muted"> <p className="muted">
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')} <Eye size={14} className="inline-status-icon" /> {t('txt_send_unavailable')}
</p> </p>
)} )}
{!!error && <p className="local-error">{error}</p>} {!!error && <p className="local-error">{error}</p>}
+77 -9
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,13 +44,30 @@ 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"> <div className="stack">
<section className="card"> <section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3> <h3 className="flush-title">{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}> <div className="muted-inline section-note">
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')} {t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div> </div>
</div> </div>
@@ -69,7 +89,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</section> </section>
<section className="card"> <section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3> <h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
@@ -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>
@@ -129,7 +169,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
{!props.loading && props.devices.length === 0 && ( {!props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <td colSpan={7}>
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div> <div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
</td> </td>
</tr> </tr>
)} )}
@@ -137,5 +177,33 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</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>
</>
); );
} }
+9 -12
View File
@@ -14,12 +14,13 @@ interface SendsPageProps {
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
uploadingSendFileName: string; uploadingSendFileName: string;
sendUploadPercent: number | null; sendUploadPercent: number | null;
mobileSidebarToggleKey: number;
onNotify: (type: 'success' | 'error', text: string) => void; onNotify: (type: 'success' | 'error', text: string) => void;
} }
type SendTypeFilter = 'all' | 'text' | 'file'; type SendTypeFilter = 'all' | 'text' | 'file';
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1'; const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)'; const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
function daysFromNow(iso: string | null | undefined, fallback: number): string { function daysFromNow(iso: string | null | undefined, fallback: number): string {
if (!iso) return String(fallback); if (!iso) return String(fallback);
@@ -107,12 +108,9 @@ export default function SendsPage(props: SendsPageProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
const onToggleSidebar = () => { if (!props.mobileSidebarToggleKey) return;
setMobileSidebarOpen((open) => !open); setMobileSidebarOpen((open) => !open);
}; }, [props.mobileSidebarToggleKey]);
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
useEffect(() => { useEffect(() => {
try { try {
@@ -325,8 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
{filteredSends.map((send, index) => ( {filteredSends.map((send, index) => (
<div <div
key={send.id} key={send.id}
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`} className={`list-item stagger-item stagger-delay-${Math.min(index, 10)} ${selectedId === send.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;
@@ -405,7 +402,7 @@ export default function SendsPage(props: SendsPageProps) {
)} )}
{isEditing && draft && ( {isEditing && draft && (
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage"> <div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '0ms' }}> <div className="card stagger-item stagger-delay-0">
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3> <h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>} {!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
<div className="field-grid"> <div className="field-grid">
@@ -505,12 +502,12 @@ export default function SendsPage(props: SendsPageProps) {
{!isEditing && selectedSend && ( {!isEditing && selectedSend && (
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage"> <div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '36ms' }}> <div className="card stagger-item stagger-delay-1">
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3> <h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div> <div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
</div> </div>
<div className="card stagger-item" style={{ animationDelay: '72ms' }}> <div className="card stagger-item stagger-delay-2">
<h4>{t('txt_send_details')}</h4> <h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div> <div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div> <div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
@@ -533,7 +530,7 @@ export default function SendsPage(props: SendsPageProps) {
</div> </div>
{!!(selectedSend.decNotes || '').trim() && ( {!!(selectedSend.decNotes || '').trim() && (
<div className="card stagger-item" style={{ animationDelay: '108ms' }}> <div className="card stagger-item stagger-delay-3">
<h4>{t('txt_notes')}</h4> <h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div> <div className="notes">{selectedSend.decNotes || ''}</div>
</div> </div>
+245 -46
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact'; import { Clipboard, KeyRound, Lightbulb, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types'; import type { Profile } from '@/lib/types';
@@ -9,14 +9,28 @@ import ConfirmDialog from '@/components/ConfirmDialog';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
totpEnabled: boolean; totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
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>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error', text: string) => void; onNotify?: (type: 'success' | 'error', text: string) => void;
} }
const LOCK_TIMEOUT_OPTIONS = [
{ value: 1, labelKey: 'txt_timeout_1_minute' },
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
{ value: 15, labelKey: 'txt_timeout_15_minutes' },
{ value: 30, labelKey: 'txt_timeout_30_minutes' },
{ value: 0, labelKey: 'txt_timeout_never' },
] as const;
function randomBase32Secret(length: number): string { function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = ''; let out = '';
@@ -37,17 +51,38 @@ function buildOtpUri(email: string, secret: string): string {
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
} }
function clearLegacyTotpSetupSecrets(): void {
if (typeof window === 'undefined') return;
const prefix = 'nodewarden.totp.secret.';
const keys: string[] = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (key?.startsWith(prefix)) keys.push(key);
}
for (const key of keys) {
window.localStorage.removeItem(key);
}
}
export default function SettingsPage(props: SettingsPageProps) { export default function SettingsPage(props: SettingsPageProps) {
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState(''); const [newPassword2, setNewPassword2] = useState('');
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || ''); const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32)); const [secret, setSecret] = useState(() => randomBase32Secret(32));
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState(''); const [recoveryCode, setRecoveryCode] = useState('');
const [apiKey, setApiKey] = useState('');
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
useEffect(() => {
clearLegacyTotpSetupSecrets();
}, []);
useEffect(() => { useEffect(() => {
if (!props.totpEnabled) { if (!props.totpEnabled) {
@@ -73,19 +108,57 @@ export default function SettingsPage(props: SettingsPageProps) {
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try { try {
await props.onEnableTotp(secret, token); await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true); setTotpLocked(true);
} catch { } catch {
// Keep inputs editable after a failed attempt. // Keep inputs editable after a failed attempt.
} }
} }
async function loadRecoveryCode(): Promise<void> { function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
const code = await props.onGetRecoveryCode(recoveryMasterPassword); setMasterPasswordPrompt(action);
setMasterPasswordPromptValue('');
}
function closeMasterPasswordPrompt(): void {
if (masterPasswordPromptSubmitting) return;
setMasterPasswordPrompt(null);
setMasterPasswordPromptValue('');
}
async function submitMasterPasswordPrompt(): Promise<void> {
if (!masterPasswordPrompt || masterPasswordPromptSubmitting) return;
const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true);
try {
if (masterPasswordPrompt === 'recovery') {
const code = await props.onGetRecoveryCode(masterPassword);
setRecoveryCode(code); setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
} else if (masterPasswordPrompt === 'apiKey') {
const key = await props.onGetApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
} else {
const key = await props.onRotateApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated'));
} }
setMasterPasswordPrompt(null);
setMasterPasswordPromptValue('');
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
} finally {
setMasterPasswordPromptSubmitting(false);
}
}
const masterPasswordPromptTitle =
masterPasswordPrompt === 'recovery'
? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key')
: t('txt_view_api_key');
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');
@@ -95,30 +168,44 @@ export default function SettingsPage(props: SettingsPageProps) {
} }
return ( return (
<div className="stack"> <div className="settings-modules-grid">
<section className="card"> <section className="card settings-module">
<h3>{t('txt_profile')}</h3> <h3>{t('txt_session_timeout')}</h3>
<div className="session-timeout-fields">
<label className="field"> <label className="field">
<span>{t('txt_password_hint_optional')}</span> <span>{t('txt_timeout_time')}</span>
<input <select
className="input" className="input"
maxLength={120} value={String(props.lockTimeoutMinutes)}
value={passwordHint} onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
placeholder={t('txt_password_hint_placeholder')}
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
/>
<div className="field-help">{t('txt_password_hint_register_help')}</div>
</label>
<button
type="button"
className="btn btn-secondary"
onClick={() => void props.onSavePasswordHint(passwordHint)}
> >
{t('txt_save_profile')} {LOCK_TIMEOUT_OPTIONS.map((option) => (
</button> <option key={option.value} value={option.value}>
{t(option.labelKey)}
</option>
))}
</select>
</label>
<label className="field">
<span>{t('txt_timeout_action')}</span>
<select
className="input"
value={props.sessionTimeoutAction}
onInput={(e) => props.onSessionTimeoutActionChange((e.currentTarget as HTMLSelectElement).value === 'logout' ? 'logout' : 'lock')}
>
<option value="logout">{t('txt_timeout_action_logout')}</option>
<option value="lock">{t('txt_timeout_action_lock')}</option>
</select>
</label>
</div>
</section> </section>
<section className="card"> <section className="card settings-module settings-module-placeholder">
<Lightbulb size={26} aria-hidden="true" />
<span>{t('txt_in_planning')}</span>
</section>
<section className="card settings-module">
<h3>{t('txt_change_master_password')}</h3> <h3>{t('txt_change_master_password')}</h3>
<label className="field"> <label className="field">
<span>{t('txt_current_password')}</span> <span>{t('txt_current_password')}</span>
@@ -149,9 +236,29 @@ export default function SettingsPage(props: SettingsPageProps) {
</button> </button>
</section> </section>
<section className="card"> <section className="card settings-module">
<div className="settings-twofactor-grid"> <h3>{t('txt_password_hint_optional')}</h3>
<div className="settings-subcard"> <label className="field">
<span>{t('txt_password_hint')}</span>
<input
className="input"
maxLength={120}
value={passwordHint}
placeholder={t('txt_password_hint_placeholder')}
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
/>
<div className="field-help">{t('txt_password_hint_register_help')}</div>
</label>
<button
type="button"
className="btn btn-secondary"
onClick={() => void props.onSavePasswordHint(passwordHint)}
>
{t('txt_save_profile')}
</button>
</section>
<section className="card settings-module">
<h3>{t('txt_totp')}</h3> <h3>{t('txt_totp')}</h3>
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>} {totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
<div className="totp-grid"> <div className="totp-grid">
@@ -196,24 +303,20 @@ export default function SettingsPage(props: SettingsPageProps) {
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_disable_totp')} {t('txt_disable_totp')}
</button> </button>
</div> </section>
<div className="settings-subcard"> <section className="card settings-module">
<h3>{t('txt_recovery_code')}</h3> <h3>{t('txt_recovery_code_and_api_key')}</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}> <div className="sensitive-actions-grid">
<div className="sensitive-action">
<div>
<h4>{t('txt_recovery_code')}</h4>
<p className="muted-inline settings-field-note">
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')} {t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
</p> </p>
<label className="field"> </div>
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions"> <div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}> <button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('recovery')}>
<ShieldCheck size={14} className="btn-icon" /> <ShieldCheck size={14} className="btn-icon" />
{t('txt_view_recovery_code')} {t('txt_view_recovery_code')}
</button> </button>
@@ -230,13 +333,109 @@ export default function SettingsPage(props: SettingsPageProps) {
</button> </button>
</div> </div>
{recoveryCode && ( {recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}> <div className="recovery-code-card">
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div> <div className="recovery-code-value">{recoveryCode}</div>
</div> </div>
)} )}
</div> </div>
<div className="sensitive-action">
<div>
<h4>{t('txt_api_key')}</h4>
<p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('apiKey')}>
<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={masterPasswordPrompt !== null}
title={masterPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={masterPasswordPromptSubmitting || !masterPasswordPromptValue.trim()}
cancelDisabled={masterPasswordPromptSubmitting}
onConfirm={() => void submitMasterPasswordPrompt()}
onCancel={closeMasterPasswordPrompt}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={masterPasswordPromptValue}
onInput={(e) => setMasterPasswordPromptValue((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<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 className="api-key-warning-panel">
<div className="api-key-warning-title">{t('txt_warning')}</div>
<div className="api-key-warning-body">{t('txt_api_key_warning_body')}</div>
</div>
<div className="api-key-credentials-panel">
<div className="api-key-credentials-title">
<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 className="api-key-credential-row">
<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);
openMasterPasswordPrompt('rotateApiKey');
}}
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>
+57 -13
View File
@@ -35,6 +35,14 @@ const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const failedIconHosts = new Set<string>(); const failedIconHosts = new Set<string>();
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
return {
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
};
}
function formatTotp(code: string): string { function formatTotp(code: string): string {
if (!code) return code; if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`; if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
@@ -65,19 +73,41 @@ 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));
const [loaded, setLoaded] = useState(false);
const markIconError = () => {
if (host) failedIconHosts.add(host);
setErrored(true);
};
const syncCachedIconState = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) {
setLoaded(true);
return;
}
markIconError();
};
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
}, [host]);
if (host && !errored) { if (host && !errored) {
return ( return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<Globe size={18} />
</span>
<img <img
className="list-icon" className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)} src={websiteIconUrl(host)}
alt="" alt=""
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => { ref={syncCachedIconState}
failedIconHosts.add(host); onLoad={() => setLoaded(true)}
setErrored(true); onError={markIconError}
}}
/> />
</span>
); );
} }
return ( return (
@@ -164,7 +194,8 @@ function SortableTotpRow(props: SortableTotpRowProps) {
} }
export default function TotpCodesPage(props: TotpCodesPageProps) { export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({}); const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
const [columnCount, setColumnCount] = useState(1); const [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => { const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
@@ -247,26 +278,39 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
useEffect(() => { useEffect(() => {
if (!totpItems.length) { if (!totpItems.length) {
setTotpMap({}); setTotpCodes({});
return; return;
} }
let stopped = false; let stopped = false;
let activeRun = 0;
let timer = 0; let timer = 0;
const tick = async () => { let currentWindowId = -1;
const refreshCodes = async () => {
const runId = ++activeRun;
const entries = await Promise.all( const entries = await Promise.all(
totpItems.map(async (cipher) => { totpItems.map(async (cipher) => {
try { try {
const next = await calcTotpNow(cipher.login?.decTotp || ''); const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next] as const; return [cipher.id, next?.code || null] as const;
} catch { } catch {
return [cipher.id, null] as const; return [cipher.id, null] as const;
} }
}) })
); );
if (!stopped) setTotpMap(Object.fromEntries(entries)); if (!stopped && runId === activeRun) setTotpCodes(Object.fromEntries(entries));
}; };
void tick();
timer = window.setInterval(() => void tick(), 1000); const tick = () => {
const next = getTotpTimeState();
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
if (next.windowId === currentWindowId) return;
currentWindowId = next.windowId;
void refreshCodes();
};
tick();
timer = window.setInterval(tick, 1000);
return () => { return () => {
stopped = true; stopped = true;
window.clearInterval(timer); window.clearInterval(timer);
@@ -322,7 +366,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
<SortableTotpRow <SortableTotpRow
key={cipher.id} key={cipher.id}
cipher={cipher} cipher={cipher}
live={totpMap[cipher.id] || null} live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
onCopy={(value) => void copyToClipboard(value)} onCopy={(value) => void copyToClipboard(value)}
/> />
))} ))}
+3 -5
View File
@@ -58,6 +58,7 @@ interface VaultPageProps {
attachmentDownloadPercent: number | null; attachmentDownloadPercent: number | null;
uploadingAttachmentName: string; uploadingAttachmentName: string;
attachmentUploadPercent: number | null; attachmentUploadPercent: number | null;
mobileSidebarToggleKey: number;
} }
@@ -131,12 +132,9 @@ export default function VaultPage(props: VaultPageProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
const onToggleSidebar = () => { if (!props.mobileSidebarToggleKey) return;
setMobileSidebarOpen((open) => !open); setMobileSidebarOpen((open) => !open);
}; }, [props.mobileSidebarToggleKey]);
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
useEffect(() => { useEffect(() => {
const onQuickAdd = () => { const onQuickAdd = () => {
@@ -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');
@@ -53,7 +105,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card"> <div className="card">
<h4>{t('txt_master_password_reprompt_2')}</h4> <h4>{t('txt_master_password_reprompt_2')}</h4>
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div> <div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
<div className="actions" style={{ marginTop: '10px' }}> <div className="actions detail-unlock-actions">
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}> <button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')} <Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
</button> </button>
@@ -65,7 +117,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card"> <div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3> <h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div> <div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>} {isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
</div> </div>
{props.selectedCipher.login && ( {props.selectedCipher.login && (
@@ -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)}
/>
</> </>
); );
} }
+1 -1
View File
@@ -299,7 +299,7 @@ export default function VaultEditor(props: VaultEditorProps) {
</DndContext> </DndContext>
{props.draft.loginFido2Credentials.length > 0 && ( {props.draft.loginFido2Credentials.length > 0 && (
<> <>
<div className="section-head" style={{ marginTop: '18px' }}> <div className="section-head passkeys-section-head">
<h4>{t('txt_passkeys')}</h4> <h4>{t('txt_passkeys')}</h4>
</div> </div>
<div className="attachment-list"> <div className="attachment-list">
@@ -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,
@@ -36,8 +36,8 @@ 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: 1180px)';
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,19 +433,41 @@ 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));
const [loaded, setLoaded] = useState(false);
const markIconError = () => {
if (host) failedIconHosts.add(host);
setErrored(true);
};
const syncCachedIconState = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) {
setLoaded(true);
return;
}
markIconError();
};
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
}, [host]);
if (host && !errored) { if (host && !errored) {
return ( return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<Globe size={18} />
</span>
<img <img
className="list-icon" className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)} src={websiteIconUrl(host)}
alt="" alt=""
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => { ref={syncCachedIconState}
failedIconHosts.add(host); onLoad={() => setLoaded(true)}
setErrored(true); onError={markIconError}
}}
/> />
</span>
); );
} }
return ( return (
+54 -1
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';
@@ -128,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
try { try {
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
clearDisableTotpDialog(); clearDisableTotpDialog();
await refetchTotpStatus(); await refetchTotpStatus();
onNotify('success', t('txt_totp_disabled')); onNotify('success', t('txt_totp_disabled'));
@@ -147,10 +149,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 +196,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
await refetchAuthorizedDevices(); await refetchAuthorizedDevices();
onNotify('success', t('txt_device_authorization_revoked')); onNotify('success', t('txt_device_authorization_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
}
})(); })();
}, },
}); });
@@ -175,6 +216,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try {
await deleteAuthorizedDevice(authedFetch, device.identifier); await deleteAuthorizedDevice(authedFetch, device.identifier);
if (device.identifier === getCurrentDeviceIdentifier()) { if (device.identifier === getCurrentDeviceIdentifier()) {
onNotify('success', t('txt_device_removed')); onNotify('success', t('txt_device_removed'));
@@ -183,6 +225,9 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
} }
await refetchAuthorizedDevices(); await refetchAuthorizedDevices();
onNotify('success', t('txt_device_removed')); onNotify('success', t('txt_device_removed'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
}
})(); })();
}, },
}); });
@@ -196,9 +241,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try {
await revokeAllAuthorizedDeviceTrust(authedFetch); await revokeAllAuthorizedDeviceTrust(authedFetch);
await refetchAuthorizedDevices(); await refetchAuthorizedDevices();
onNotify('success', t('txt_all_device_authorizations_revoked')); 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 +261,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try {
await deleteAllAuthorizedDevices(authedFetch); await deleteAllAuthorizedDevices(authedFetch);
onNotify('success', t('txt_all_devices_removed')); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow(); onLogoutNow();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
}
})(); })();
}, },
}); });
+62 -3
View File
@@ -122,9 +122,11 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY); const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Profile; const parsed = JSON.parse(raw) as Profile;
if (!parsed?.email || !parsed?.key) return null; if (!parsed?.email) return null;
if (email && parsed.email !== email) return null; if (email && parsed.email !== email) return null;
return parsed; const snapshot = stripProfileSecrets(parsed);
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
return snapshot;
} catch { } catch {
return null; return null;
} }
@@ -132,13 +134,27 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
export function saveProfileSnapshot(profile: Profile | null): void { export function saveProfileSnapshot(profile: Profile | null): void {
if (!profile) return; if (!profile) return;
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile)); localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile)));
} }
export function clearProfileSnapshot(): void { export function clearProfileSnapshot(): void {
localStorage.removeItem(PROFILE_SNAPSHOT_KEY); localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
} }
export function stripProfileSecrets(profile: Profile | null): Profile | null {
if (!profile) return null;
return {
id: String(profile.id || ''),
email: String(profile.email || ''),
name: String(profile.name || ''),
role: profile.role === 'admin' ? 'admin' : 'user',
masterPasswordHint: profile.masterPasswordHint ?? null,
publicKey: profile.publicKey ?? null,
key: '',
privateKey: null,
};
}
export function getCurrentDeviceIdentifier(): string { export function getCurrentDeviceIdentifier(): string {
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
} }
@@ -575,7 +591,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 || '');
}
+19 -10
View File
@@ -260,18 +260,24 @@ async function buildPublicSendAccessPayload(password?: string, keyPart?: string
return payload; return payload;
} }
export async function accessPublicSend(accessId: string, keyPart?: string | null, password?: string): Promise<any> { export async function accessPublicSend(
accessId: string,
keyPart?: string | null,
password?: string,
options?: { signal?: AbortSignal }
): Promise<unknown> {
const payload = await buildPublicSendAccessPayload(password, keyPart); const payload = await buildPublicSendAccessPayload(password, keyPart);
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, { const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: options?.signal,
}); });
if (!resp.ok) { if (!resp.ok) {
const message = await parseErrorMessage(resp, 'Failed to access send'); const message = await parseErrorMessage(resp, 'Failed to access send');
throw createApiError(message, resp.status); throw createApiError(message, resp.status);
} }
return (await parseJson<any>(resp)) || null; return (await parseJson<unknown>(resp)) || null;
} }
export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> { export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> {
@@ -290,19 +296,22 @@ export async function accessPublicSendFile(sendId: string, fileId: string, keyPa
return body.url; return body.url;
} }
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> { export async function decryptPublicSend(accessData: unknown, urlSafeKey: string): Promise<unknown> {
const sendKeyMaterial = base64UrlToBytes(urlSafeKey); const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
const sendKey = await toSendKeyParts(sendKeyMaterial); const sendKey = await toSendKeyParts(sendKeyMaterial);
const out: any = { ...accessData }; const source = accessData && typeof accessData === 'object' ? accessData as Record<string, unknown> : {};
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac); const text = source.text && typeof source.text === 'object' ? source.text as Record<string, unknown> : null;
if (accessData?.text?.text) { const file = source.file && typeof source.file === 'object' ? source.file as Record<string, unknown> : null;
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac); const out: Record<string, unknown> = { ...source };
out.decName = await decryptStr(String(source.name || ''), sendKey.enc, sendKey.mac);
if (text?.text) {
out.decText = await decryptStr(String(text.text), sendKey.enc, sendKey.mac);
} }
if (accessData?.file?.fileName) { if (file?.fileName) {
try { try {
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac); out.decFileName = await decryptStr(String(file.fileName), sendKey.enc, sendKey.mac);
} catch { } catch {
out.decFileName = String(accessData.file.fileName); out.decFileName = String(file.fileName);
} }
} }
return out; return out;
+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 || {};
+215 -17
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,
@@ -12,6 +13,7 @@ import {
parseErrorMessage, parseErrorMessage,
parseJson, parseJson,
uploadDirectEncryptedPayload, uploadDirectEncryptedPayload,
uploadWithProgress,
type AuthedFetch, type AuthedFetch,
} from './shared'; } from './shared';
import { readResponseBytesWithProgress } from '../download'; import { readResponseBytesWithProgress } from '../download';
@@ -272,6 +274,98 @@ export async function deleteCipherAttachment(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed')); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed'));
} }
export async function repairCipherAttachmentMetadata(
authedFetch: AuthedFetch,
cipherId: string,
attachmentId: string,
metadata: { fileName?: string; key?: string | null }
): Promise<void> {
const resp = await authedFetch(
`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}/metadata`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
}
);
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Update attachment metadata failed'));
}
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
async function decryptCipherStringWithKey(
value: string,
enc: Uint8Array,
mac: Uint8Array
): Promise<Uint8Array | null> {
try {
return await decryptBw(value, enc, mac);
} catch {
return null;
}
}
async function decryptAttachmentFileName(
rawFileName: string,
itemKeys: { enc: Uint8Array; mac: Uint8Array },
userKeys: { enc: Uint8Array; mac: Uint8Array }
): Promise<{ fileName: string; source: 'plain' | 'item' | 'user' }> {
const fallback = rawFileName || 'attachment.bin';
if (!rawFileName || !looksLikeCipherString(rawFileName)) return { fileName: fallback, source: 'plain' };
try {
const fileName = await decryptStr(rawFileName, itemKeys.enc, itemKeys.mac);
if (fileName) return { fileName, source: 'item' };
} catch {
// 继续尝试旧 user key 文件名。
}
if (!sameBytes(itemKeys.enc, userKeys.enc) || !sameBytes(itemKeys.mac, userKeys.mac)) {
try {
const fileName = await decryptStr(rawFileName, userKeys.enc, userKeys.mac);
if (fileName) return { fileName, source: 'user' };
} catch {
// 保留原始文件名。
}
}
return { fileName: fallback, source: 'plain' };
}
type AttachmentDecryptMode = 'attachment-item' | 'attachment-user' | 'legacy-item' | 'legacy-user';
interface AttachmentDecryptCandidate {
mode: AttachmentDecryptMode;
enc: Uint8Array;
mac: Uint8Array;
rawAttachmentKey: Uint8Array | null;
}
async function uploadRepairedAttachmentBlob(
authedFetch: AuthedFetch,
session: SessionState,
cipherId: string,
attachmentId: string,
encryptedBytes: Uint8Array
): Promise<void> {
if (!session.accessToken) throw new Error('Unauthorized');
const payload = new ArrayBuffer(encryptedBytes.byteLength);
new Uint8Array(payload).set(encryptedBytes);
const resp = await uploadWithProgress(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`, {
accessToken: session.accessToken,
method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream' },
body: payload,
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair attachment upload failed'));
}
export async function downloadCipherAttachmentDecrypted( export async function downloadCipherAttachmentDecrypted(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
session: SessionState, session: SessionState,
@@ -292,32 +386,76 @@ export async function downloadCipherAttachmentDecrypted(
const userEnc = base64ToBytes(session.symEncKey); const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey); const userMac = base64ToBytes(session.symMacKey);
const itemKeys = await getCipherKeys(cipher, userEnc, userMac); const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
const userKeys = { enc: userEnc, mac: userMac };
let fileEnc = itemKeys.enc; const candidates: AttachmentDecryptCandidate[] = [];
let fileMac = itemKeys.mac;
const keyCipher = String(info.key || '').trim(); const keyCipher = String(info.key || '').trim();
if (keyCipher && looksLikeCipherString(keyCipher)) { if (keyCipher && looksLikeCipherString(keyCipher)) {
try { const itemWrappedKey = await decryptCipherStringWithKey(keyCipher, itemKeys.enc, itemKeys.mac);
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac); if (itemWrappedKey && itemWrappedKey.length >= 64) {
if (fileRawKey.length >= 64) { candidates.push({
fileEnc = fileRawKey.slice(0, 32); mode: 'attachment-item',
fileMac = fileRawKey.slice(32, 64); enc: itemWrappedKey.slice(0, 32),
} mac: itemWrappedKey.slice(32, 64),
} catch { rawAttachmentKey: itemWrappedKey,
// fallback to item key });
}
} }
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac); if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) {
const userWrappedKey = await decryptCipherStringWithKey(keyCipher, userEnc, userMac);
if (userWrappedKey && userWrappedKey.length >= 64) {
candidates.push({
mode: 'attachment-user',
enc: userWrappedKey.slice(0, 32),
mac: userWrappedKey.slice(32, 64),
rawAttachmentKey: userWrappedKey,
});
}
}
}
candidates.push({ mode: 'legacy-item', enc: itemKeys.enc, mac: itemKeys.mac, rawAttachmentKey: null });
if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) {
candidates.push({ mode: 'legacy-user', enc: userEnc, mac: userMac, rawAttachmentKey: null });
}
let plainBytes: Uint8Array | null = null;
let usedCandidate: AttachmentDecryptCandidate | null = null;
for (const candidate of candidates) {
try {
plainBytes = await decryptBwFileData(encryptedBytes, candidate.enc, candidate.mac);
usedCandidate = candidate;
break;
} catch {
// 继续尝试下一种旧附件格式。
}
}
if (!plainBytes || !usedCandidate) throw new Error('Attachment decryption failed');
const fileNameRaw = String(info.fileName || '').trim(); const fileNameRaw = String(info.fileName || '').trim();
let fileName = fileNameRaw || `attachment-${aid}`; const nameResult = await decryptAttachmentFileName(fileNameRaw, itemKeys, userKeys);
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { const fileName = nameResult.fileName || `attachment-${aid}`;
try { try {
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName; const metadata: { fileName?: string; key?: string | null } = {};
} catch { if (nameResult.source === 'user') {
// keep fallback name metadata.fileName = await encryptTextValue(fileName, itemKeys.enc, itemKeys.mac) || undefined;
} }
if (usedCandidate.mode === 'attachment-user' && usedCandidate.rawAttachmentKey) {
metadata.key = await encryptBw(usedCandidate.rawAttachmentKey, itemKeys.enc, itemKeys.mac);
} else if (usedCandidate.mode === 'legacy-item') {
metadata.key = null;
} else if (usedCandidate.mode === 'legacy-user') {
const repairedBytes = await encryptBwFileData(plainBytes, itemKeys.enc, itemKeys.mac);
await uploadRepairedAttachmentBlob(authedFetch, session, cid, aid, repairedBytes);
metadata.key = null;
}
if (Object.keys(metadata).length > 0) {
await repairCipherAttachmentMetadata(authedFetch, cid, aid, metadata);
}
} catch {
// 修复失败不影响本次下载,旧附件内容已经成功解密。
} }
return { fileName, bytes: plainBytes }; return { fileName, bytes: plainBytes };
@@ -346,6 +484,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 +666,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 +681,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 +690,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 +704,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),
+28 -8
View File
@@ -372,16 +372,36 @@ export async function performRegistration(args: {
export async function performUnlock( export async function performUnlock(
session: SessionState, session: SessionState,
profile: Profile, profile: Profile | null,
password: string, password: string,
fallbackIterations: number fallbackIterations: number
): Promise<SessionState> { ): Promise<PasswordLoginResult> {
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations); const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
const keys = await unlockVaultKey(profile.key, derived.masterKey); const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
const refreshedSession = await maybeRefreshSession(session); const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
if (!refreshedSession) {
throw new Error('Session expired'); if ('access_token' in token && token.access_token) {
return {
kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey),
};
} }
return { ...refreshedSession, ...keys };
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
return {
kind: 'totp',
pendingTotp: {
email: normalizedEmail,
passwordHash: derived.hash,
masterKey: derived.masterKey,
},
};
}
return {
kind: 'error',
message: tokenError.error_description || tokenError.error || 'Unlock failed',
};
} }
+94
View File
@@ -309,6 +309,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_bulk_delete_sends_failed: "Bulk delete sends failed", txt_bulk_delete_sends_failed: "Bulk delete sends failed",
txt_bulk_move_failed: "Bulk move failed", txt_bulk_move_failed: "Bulk move failed",
txt_cancel: "Cancel", txt_cancel: "Cancel",
txt_continue: "Continue",
txt_card: "Card", txt_card: "Card",
txt_card_details: "Card Details", txt_card_details: "Card Details",
txt_cardholder_name: "Cardholder Name", txt_cardholder_name: "Cardholder Name",
@@ -387,6 +388,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",
@@ -414,6 +418,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_encrypted_file_2: "Encrypted file", txt_encrypted_file_2: "Encrypted file",
txt_enter_a_folder_name: "Enter a folder name.", txt_enter_a_folder_name: "Enter a folder name.",
txt_enter_master_password_to_disable_two_step_verification: "Enter master password to disable two-step verification.", txt_enter_master_password_to_disable_two_step_verification: "Enter master password to disable two-step verification.",
txt_enter_master_password_to_continue: "Enter your master password to continue.",
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.", txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
txt_expiration_date: "Expiration Date", txt_expiration_date: "Expiration Date",
txt_expiration_days_0_never: "Expiration Days (0 = never)", txt_expiration_days_0_never: "Expiration Days (0 = never)",
@@ -459,6 +464,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 +557,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",
@@ -592,9 +600,25 @@ const messages: Record<Locale, Record<string, string>> = {
txt_recover_two_step_login: "Recover Two-step Login", txt_recover_two_step_login: "Recover Two-step Login",
txt_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.", txt_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.",
txt_recovery_code: "Recovery Code", txt_recovery_code: "Recovery Code",
txt_recovery_code_and_api_key: "Recovery Code and API Key",
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 +642,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",
@@ -1018,6 +1044,7 @@ const zhCNOverrides: Record<string, string> = {
txt_confirm_master_password: '确认主密码', txt_confirm_master_password: '确认主密码',
txt_submit: '提交', txt_submit: '提交',
txt_cancel: '取消', txt_cancel: '取消',
txt_continue: '继续',
txt_yes: '是', txt_yes: '是',
txt_no: '否', txt_no: '否',
txt_loading: '加载中...', txt_loading: '加载中...',
@@ -1067,7 +1094,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 +1143,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: '用户',
@@ -1277,6 +1312,7 @@ const zhCNOverrides: Record<string, string> = {
txt_encrypted_file_2: '加密文件', txt_encrypted_file_2: '加密文件',
txt_enter_a_folder_name: '请输入文件夹名称', txt_enter_a_folder_name: '请输入文件夹名称',
txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证', txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证',
txt_enter_master_password_to_continue: '输入主密码以继续',
txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目', txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目',
txt_expiry: '有效期', txt_expiry: '有效期',
txt_expiry_month: '有效期月', txt_expiry_month: '有效期月',
@@ -1345,8 +1381,24 @@ const zhCNOverrides: Record<string, string> = {
txt_recover_2fa_failed: '恢复 2FA 失败', txt_recover_2fa_failed: '恢复 2FA 失败',
txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录', txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录',
txt_recovery_code_copied: '恢复代码已复制', txt_recovery_code_copied: '恢复代码已复制',
txt_recovery_code_and_api_key: '恢复代码和 API 密钥',
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: '移除设备',
@@ -1439,6 +1491,48 @@ zhCNOverrides.txt_lock = '锁定';
zhCNOverrides.txt_menu = '菜单'; zhCNOverrides.txt_menu = '菜单';
zhCNOverrides.txt_settings = '设置'; zhCNOverrides.txt_settings = '设置';
zhCNOverrides.txt_back = '返回'; zhCNOverrides.txt_back = '返回';
messages.en.txt_auto_lock = 'Auto-lock';
messages.en.txt_auto_lock_description = 'Locks after inactivity. Closing and reopening the page always starts locked.';
messages.en.txt_auto_lock_updated = 'Auto-lock updated';
messages.en.txt_session_timeout = 'Session timeout';
messages.en.txt_session_timeout_updated = 'Session timeout updated';
messages.en.txt_timeout_time = 'Timeout time';
messages.en.txt_timeout_action = 'Timeout action';
messages.en.txt_timeout_action_logout = 'Log out';
messages.en.txt_timeout_action_lock = 'Lock';
messages.en.txt_in_planning = 'In planning';
messages.en.txt_security_preferences = 'Security Preferences';
messages.en.txt_timeout_1_minute = '1 minute';
messages.en.txt_timeout_5_minutes = '5 minutes';
messages.en.txt_timeout_15_minutes = '15 minutes';
messages.en.txt_timeout_30_minutes = '30 minutes';
messages.en.txt_timeout_never = 'Never';
messages.en.txt_lock_after_1_minute = 'After 1 minute';
messages.en.txt_lock_after_5_minutes = 'After 5 minutes';
messages.en.txt_lock_after_15_minutes = 'After 15 minutes';
messages.en.txt_lock_after_30_minutes = 'After 30 minutes';
messages.en.txt_lock_after_never = 'Never for inactivity';
zhCNOverrides.txt_auto_lock = '会话超时';
zhCNOverrides.txt_auto_lock_description = '页面闲置后执行会话超时动作;关闭页面或浏览器后再次打开始终进入锁定页。';
zhCNOverrides.txt_auto_lock_updated = '会话超时已更新';
zhCNOverrides.txt_session_timeout = '会话超时';
zhCNOverrides.txt_session_timeout_updated = '会话超时已更新';
zhCNOverrides.txt_timeout_time = '超时时间';
zhCNOverrides.txt_timeout_action = '超时动作';
zhCNOverrides.txt_timeout_action_logout = '注销';
zhCNOverrides.txt_timeout_action_lock = '锁定';
zhCNOverrides.txt_in_planning = '构思中';
zhCNOverrides.txt_security_preferences = '安全偏好';
zhCNOverrides.txt_timeout_1_minute = '1 分钟';
zhCNOverrides.txt_timeout_5_minutes = '5 分钟';
zhCNOverrides.txt_timeout_15_minutes = '15 分钟';
zhCNOverrides.txt_timeout_30_minutes = '30 分钟';
zhCNOverrides.txt_timeout_never = '从不';
zhCNOverrides.txt_lock_after_1_minute = '闲置 1 分钟后';
zhCNOverrides.txt_lock_after_5_minutes = '闲置 5 分钟后';
zhCNOverrides.txt_lock_after_15_minutes = '闲置 15 分钟后';
zhCNOverrides.txt_lock_after_30_minutes = '闲置 30 分钟后';
zhCNOverrides.txt_lock_after_never = '不因闲置锁定';
zhCNOverrides.txt_attachments = '附件'; zhCNOverrides.txt_attachments = '附件';
zhCNOverrides.txt_upload_attachments = '上传附件'; zhCNOverrides.txt_upload_attachments = '上传附件';
zhCNOverrides.txt_new_attachments = '待上传附件'; zhCNOverrides.txt_new_attachments = '待上传附件';
+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;
+1
View File
@@ -1,6 +1,7 @@
import { render } from 'preact'; import { render } from 'preact';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App'; import App from './App';
import './tailwind.css';
import './styles.css'; import './styles.css';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
+205 -4909
View File
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
.loading-screen {
@apply grid h-full place-items-center text-lg text-muted;
}
.auth-page {
@apply relative grid min-h-full place-items-center bg-transparent p-6;
}
.public-send-page {
@apply min-h-[80vh] items-center;
justify-items: center;
}
.public-send-title {
@apply mt-2;
}
.public-send-card {
@apply mt-2.5;
}
.inline-status-icon {
@apply align-text-bottom;
}
.auth-card {
@apply relative w-full overflow-hidden border bg-panel p-[30px] shadow-elevated;
border-color: var(--line);
@apply rounded-[22px];
}
.auth-card h1 {
@apply m-0 mb-1 text-center;
}
.standalone-shell {
@apply grid w-[min(640px,100%)] gap-3.5;
}
.standalone-brand {
@apply mb-3 inline-flex items-center gap-3.5;
}
.standalone-brand-outside {
@apply mb-0.5 w-full justify-center;
}
.standalone-brand-logo {
@apply h-14 w-14 flex-shrink-0 object-contain;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
}
.standalone-brand-wordmark {
@apply block h-auto max-w-full;
width: clamp(200px, 30vw, 360px);
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.18));
}
.standalone-title {
@apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal;
}
.standalone-muted {
@apply text-left;
}
.jwt-warning-head {
@apply mb-2.5 flex items-center justify-center gap-2.5 text-center;
color: #b45309;
}
.jwt-warning-box {
@apply rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3;
}
.jwt-warning-label {
@apply mb-1.5 text-[13px] font-bold;
color: #92400e;
}
.jwt-warning-copy {
@apply m-0 mb-3.5 leading-[1.6];
color: #475569;
}
.jwt-warning-list {
@apply m-0 pl-[18px] leading-[1.55];
color: #334155;
}
.jwt-inline-link {
@apply font-bold no-underline;
color: #1d4ed8;
}
.jwt-inline-link:hover {
text-decoration: underline;
}
.jwt-secret-fields {
@apply mt-2 grid gap-1.5;
}
.jwt-secret-row {
@apply grid items-start gap-2;
grid-template-columns: 88px minmax(0, 1fr);
}
.jwt-secret-row > span {
color: #64748b;
}
.jwt-generator {
@apply mt-3.5;
}
.jwt-generator-actions {
@apply mt-2.5 flex flex-wrap items-center gap-2.5;
}
.jwt-copy-hint {
@apply text-[13px] font-bold;
color: #15803d;
}
.standalone-footer {
@apply w-full text-center text-[13px] text-slate-500;
}
.standalone-footer a {
@apply font-bold no-underline;
color: #1d4ed8;
}
.standalone-footer a:hover {
text-decoration: underline;
}
.standalone-version {
@apply font-bold;
color: #1d4ed8;
}
+22
View File
@@ -0,0 +1,22 @@
* {
@apply box-border;
}
html,
body,
#root {
@apply m-0 h-full w-full p-0;
color: var(--text);
background: var(--bg-accent);
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
}
body {
@apply relative antialiased;
transition: background-color var(--dur-medium) var(--ease-smooth), color var(--dur-medium) var(--ease-smooth);
}
body.dialog-open {
@apply overflow-hidden;
overscroll-behavior: contain;
}
+145
View File
@@ -0,0 +1,145 @@
:root[data-theme='dark'] body,
:root[data-theme='dark'] #root,
:root[data-theme='dark'] .app-page,
:root[data-theme='dark'] .auth-page {
background: var(--bg-accent);
color: var(--text);
}
:root[data-theme='dark'] h1,
:root[data-theme='dark'] h2,
:root[data-theme='dark'] h3,
:root[data-theme='dark'] h4,
: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'] .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'] .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,
: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'] .tree-label {
color: var(--muted);
}
:root[data-theme='dark'] .input,
:root[data-theme='dark'] .textarea,
:root[data-theme='dark'] select.input,
:root[data-theme='dark'] .search-input,
:root[data-theme='dark'] .dialog input,
:root[data-theme='dark'] .dialog textarea,
:root[data-theme='dark'] .dialog select {
background: var(--panel);
border-color: var(--line);
color: var(--text);
box-shadow: none;
}
: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: color-mix(in srgb, var(--muted) 76%, transparent);
}
: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: color-mix(in srgb, var(--primary) 54%, var(--line));
background: var(--panel);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
}
:root[data-theme='dark'] .input-readonly {
background: var(--panel-muted);
color: var(--muted-strong);
}
:root[data-theme='dark'] .input:disabled,
:root[data-theme='dark'] .btn:disabled {
background: var(--panel-muted);
border-color: var(--line-soft);
color: color-mix(in srgb, var(--muted) 62%, transparent);
}
:root[data-theme='dark'] .mobile-sidebar-mask,
:root[data-theme='dark'] .dialog-mask {
background: var(--overlay-strong);
}
:root[data-theme='dark'] .toast-item {
background: var(--panel);
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .toast-item.error,
:root[data-theme='dark'] .toast-item.warning {
border-color: color-mix(in srgb, var(--danger) 36%, var(--line));
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
color: var(--text);
}
: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: var(--warning);
}
:root[data-theme='dark'] .local-error {
color: var(--danger);
}
:root[data-theme='dark'] .status-ok {
color: var(--success);
}
:root[data-theme='dark'] .totp-qr,
:root[data-theme='dark'] .totp-qr svg,
:root[data-theme='dark'] .totp-qr img {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
}
+202
View File
@@ -0,0 +1,202 @@
.muted {
@apply m-0 mb-4 text-center leading-relaxed text-muted;
}
.field {
@apply mb-3.5 block;
}
.field > span {
@apply mb-2 mt-2.5 block text-sm font-semibold;
}
.input {
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-none transition;
background: var(--panel);
border-color: rgba(74, 103, 150, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
}
select.input {
@apply pr-[42px];
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
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 {
@apply h-auto min-h-12 px-2.5 py-2 text-sm leading-[1.4];
}
input[type='file'].input::file-selector-button {
@apply mr-2.5 h-8 cursor-pointer rounded-full border px-3 font-bold;
background: #eef4ff;
border-color: #9db8ea;
color: #1f4ea0;
}
input[type='file'].input::file-selector-button:hover {
background: #dfeaff;
border-color: #2f5fd8;
}
.textarea {
@apply h-auto min-h-28 resize-y;
}
.input:focus {
border-color: rgba(43, 102, 217, 0.6);
background-color: #fbfdff;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.10), 0 8px 18px rgba(37, 99, 235, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.95);
}
.input-readonly {
@apply bg-slate-100 text-slate-600;
}
.input:disabled {
@apply cursor-not-allowed border-slate-300 bg-slate-200 text-slate-400;
}
.password-wrap {
@apply relative;
}
.password-wrap .input {
@apply pr-11;
}
.password-toggle {
@apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition;
transform: translateY(-50%);
}
.eye-btn {
@apply absolute bottom-2.5 right-2.5 grid h-8 w-8 cursor-pointer place-items-center border-0 bg-transparent text-slate-700 transition;
}
.password-toggle:hover,
.eye-btn:hover {
color: var(--primary);
transform: translateY(-1px) scale(1.04);
}
.btn {
@apply inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-full border border-transparent px-4 text-[15px] font-bold no-underline transition;
}
.topbar-actions .btn,
.user-chip,
.side-link,
.mobile-tab {
@apply relative overflow-hidden;
}
.topbar-actions .btn::before,
.user-chip::before,
.side-link::before,
.mobile-tab::before {
content: '';
@apply absolute left-1/2 top-1/2 h-[110px] w-[110px] rounded-full opacity-0;
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);
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: 0;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.btn:active:not(:disabled) {
transform: translateY(0) scale(0.985);
}
.btn-icon {
@apply shrink-0;
}
.btn.full {
@apply my-2.5 h-12 w-full text-lg;
}
.btn-primary {
@apply border-blue-700/30 bg-blue-600 text-white;
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.20);
}
.btn-primary:hover {
@apply bg-blue-700;
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.22);
}
.btn-secondary {
@apply bg-panel text-brand-strong;
border-color: rgba(37, 99, 235, 0.20);
box-shadow: 0 6px 14px rgba(13, 31, 68, 0.04);
}
.btn-secondary:hover {
background: #f4f8ff;
border-color: rgba(37, 99, 235, 0.34);
}
.btn-danger {
@apply bg-white/80 text-danger;
border-color: rgba(217, 45, 87, 0.28);
}
.btn-danger:hover {
background: rgba(255, 241, 242, 0.96);
border-color: rgba(217, 45, 87, 0.38);
}
.btn:disabled {
@apply cursor-not-allowed border-slate-300 bg-slate-200 text-slate-400;
}
.or {
@apply my-2.5 text-center text-slate-700;
}
.field-help {
@apply mt-2 text-[13px] leading-normal text-slate-500;
}
.check-line-compact {
@apply mb-0;
}
.auth-support-row {
@apply -mt-0.5 mb-3 flex items-center justify-between gap-2.5;
}
.auth-link-btn {
@apply cursor-pointer border-0 bg-transparent p-0 text-[13px] font-bold text-blue-700 transition;
}
.auth-link-btn:hover {
text-decoration: underline;
transform: translateX(2px);
}
.auth-link-btn:disabled {
@apply cursor-not-allowed text-slate-400 no-underline;
}
+856
View File
@@ -0,0 +1,856 @@
.stack {
@apply grid gap-3;
}
.import-export-page {
@apply grid gap-3;
}
.import-export-panels {
@apply grid items-start gap-3;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.backup-grid {
@apply grid items-start gap-3 p-0.5;
grid-template-columns: 280px 280px minmax(0, 1fr);
}
.backup-operations-sidebar,
.backup-destination-sidebar,
.backup-detail-panel {
@apply min-w-0 rounded-xl bg-white p-3;
border: 1px solid #d8dee8;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.backup-actions-stack {
@apply grid gap-2.5;
}
.backup-option-field {
@apply inline-flex items-center gap-2;
}
.backup-option-label {
@apply m-0 inline-flex cursor-pointer items-center gap-2 text-[15px] font-bold;
color: #0f172a;
}
.backup-option-label input[type='checkbox'] {
@apply m-0 h-[22px] w-[22px] shrink-0;
}
.backup-help-wrap {
@apply relative inline-flex items-center;
}
.backup-help-trigger {
@apply inline-flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-full p-0 text-[13px] font-extrabold leading-none;
border: 1px solid #bfd1f3;
background: #eef4ff;
color: #1d4ed8;
}
.backup-help-trigger:hover,
.backup-help-trigger:focus-visible {
border-color: #7ea4ef;
background: #e1ecff;
outline: none;
}
.backup-help-bubble {
@apply invisible pointer-events-none absolute px-3 py-2.5 text-[13px] leading-[1.55] opacity-0;
left: 50%;
top: calc(100% + 10px);
z-index: 30;
width: min(320px, calc(100vw - 40px));
border: 1px solid #d5dce7;
@apply rounded-xl;
background: #ffffff;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.14);
color: #475467;
transform: translate(-50%, -4px);
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
}
.backup-help-bubble::before {
content: '';
@apply absolute h-2.5 w-2.5;
left: 50%;
top: -6px;
background: #ffffff;
border-left: 1px solid #d5dce7;
border-top: 1px solid #d5dce7;
transform: translateX(-50%) rotate(45deg);
}
.backup-help-wrap:hover .backup-help-bubble,
.backup-help-wrap:focus-within .backup-help-bubble,
.backup-help-wrap.open .backup-help-bubble {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate(-50%, 0);
}
.backup-recommendation-list {
@apply grid gap-2;
}
.backup-recommendation-group + .backup-recommendation-group {
@apply mt-3;
}
.backup-recommendation-group-title {
@apply m-0 mb-2 text-sm font-bold;
color: #0f172a;
}
.backup-recommendation-row {
@apply flex items-center justify-between gap-3;
}
.backup-recommendation-linked {
@apply grid gap-1;
}
.backup-recommendation-linked-item {
@apply flex items-center justify-between gap-3 text-xs;
color: #475467;
}
.backup-recommendation-card {
@apply grid gap-3 rounded-xl p-3.5;
border: 1px solid var(--line);
background: #f8fbff;
}
.backup-recommendation-header {
@apply flex items-start justify-between gap-3;
}
.backup-recommendation-actions {
@apply flex flex-wrap gap-2;
}
.backup-recommendation-steps {
@apply grid gap-2;
}
.backup-recommendation-step {
color: #475467;
line-height: 1.5;
}
.backup-recommendation-inline-note {
color: #475467;
line-height: 1.5;
}
.backup-recommendation-dav-list {
@apply grid gap-2;
}
.backup-recommendation-dav-item {
@apply grid gap-1 rounded-[10px] bg-white px-3 py-2.5;
border: 1px solid var(--line);
}
.backup-recommendation-dav-item code {
overflow-wrap: anywhere;
}
.backup-destination-list {
@apply grid gap-2;
}
.backup-destination-item {
@apply grid w-full cursor-pointer gap-1.5 rounded-xl bg-white p-3 text-left;
border: 1px solid var(--line);
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.backup-destination-item:hover {
border-color: #93c5fd;
background: #f8fbff;
}
.backup-destination-item.active {
border-color: var(--primary);
background: #eff6ff;
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.08);
}
.backup-destination-top {
@apply flex items-center justify-between gap-3;
}
.backup-destination-name {
@apply font-bold;
color: #0f172a;
overflow-wrap: anywhere;
}
.backup-destination-type {
@apply whitespace-nowrap rounded-full px-2 py-0.5 text-xs;
background: #e2e8f0;
color: #334155;
}
.backup-destination-meta {
@apply text-[13px] leading-[1.4];
color: #64748b;
}
.backup-destination-addbar {
@apply mt-2.5;
}
.backup-add-chooser {
@apply mt-2.5 flex flex-wrap gap-2;
}
.backup-name-row {
@apply mb-2 grid items-end gap-2.5;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.backup-name-field {
@apply m-0;
grid-column: 1 / span 3;
}
.backup-type-field {
@apply m-0;
grid-column: 4;
}
.backup-detail-schedule-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.backup-interval-row {
@apply grid items-start gap-2.5;
grid-template-columns: minmax(0, 1fr) 86px;
}
.backup-interval-presets {
@apply grid gap-[3px];
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.backup-interval-preset {
@apply h-[22px] cursor-pointer rounded-full text-xs font-bold;
border: 1px solid #cdd7e6;
background: #f8fafc;
color: #475569;
transition:
border-color var(--dur-fast) var(--ease-smooth),
background-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);
}
.backup-interval-preset:hover:not(:disabled) {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.backup-interval-preset.active {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.backup-interval-preset:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.backup-inline-suffix-wrap {
@apply relative w-full;
}
.backup-inline-suffix {
@apply pointer-events-none absolute right-3 top-1/2 text-[13px] font-bold;
transform: translateY(-50%);
color: #64748b;
}
.backup-schedule-attachments-row {
@apply mb-1;
}
.backup-divider {
@apply my-3.5 h-px;
background: var(--line);
}
.backup-browser-path {
@apply mb-2.5 flex items-center gap-2 rounded-[10px] px-3 py-2.5;
border: 1px solid var(--line);
background: #f8fafc;
overflow-wrap: anywhere;
}
.backup-browser-path strong {
color: #0f172a;
}
.backup-browser-nav {
@apply mb-2.5;
}
.backup-browser-list {
@apply overflow-hidden rounded-xl bg-white;
border: 1px solid var(--line);
}
.backup-browser-pagination {
@apply mt-2.5 flex items-center justify-end gap-2.5;
}
.backup-browser-page-indicator {
@apply min-w-12 text-center text-[13px] font-bold;
color: #64748b;
}
.backup-browser-row + .backup-browser-row {
border-top: 1px solid var(--line);
}
.backup-browser-row {
@apply grid items-center gap-2.5 px-3 py-2.5;
grid-template-columns: minmax(0, 1fr) auto auto;
}
.backup-browser-entry {
@apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left;
color: #0f172a;
}
.backup-browser-entry.file {
cursor: default;
}
.backup-browser-name {
@apply font-bold;
overflow-wrap: anywhere;
}
.backup-browser-meta {
@apply grid justify-items-end gap-1 text-right text-[13px];
color: #64748b;
}
.backup-browser-actions {
justify-content: flex-end;
}
.backup-browser-empty {
@apply rounded-xl px-3.5 py-[18px] text-center;
border: 1px dashed var(--line);
color: #64748b;
}
.backup-inline-note {
@apply m-0 mb-3 leading-[1.5];
color: #64748b;
}
.import-export-panel h3 {
@apply m-0 mb-1.5;
}
.field-grid {
@apply grid gap-2.5;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field-span-2 {
grid-column: 1 / -1;
}
.totp-grid {
@apply mb-3.5 grid gap-3.5;
grid-template-columns: 220px 1fr;
}
.totp-qr {
@apply grid min-h-[220px] place-items-center rounded-[10px] bg-white p-2;
border: 1px solid var(--line);
}
.totp-qr svg {
@apply h-[180px] w-[180px];
}
.totp-qr img {
@apply h-[180px] w-[180px] rounded-lg bg-white;
}
.section-head {
@apply mb-2.5 flex items-center justify-between;
}
.flush-title {
@apply m-0;
}
.section-title-flush {
@apply mt-0;
}
.section-note {
@apply mt-1;
}
.empty-comfortable {
@apply min-h-20;
}
.actions {
@apply flex flex-wrap gap-2;
}
.muted-inline {
@apply self-center text-sm;
color: var(--muted);
}
.settings-field-note {
@apply mb-2;
}
.settings-modules-grid {
@apply grid gap-3;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-module {
@apply min-w-0;
}
.settings-module h3 {
@apply mb-4 mt-0 text-base font-extrabold;
color: var(--text);
}
.settings-module-placeholder {
@apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold;
color: var(--muted);
}
.settings-module-placeholder svg {
color: var(--primary-strong);
}
.settings-module .field:last-child,
.session-timeout-fields .field {
@apply mb-0;
}
.session-timeout-fields {
@apply grid gap-3;
}
.sensitive-actions-grid {
@apply grid gap-3;
}
.sensitive-action {
@apply rounded-lg border p-3.5;
border-color: var(--line-soft);
background: color-mix(in srgb, var(--surface) 74%, transparent);
}
.sensitive-action h4 {
@apply mb-1 mt-0 text-base font-extrabold;
color: var(--text);
}
.recovery-code-card {
@apply mb-0 mt-2.5 rounded-lg border p-3;
border-color: var(--line-soft);
background: color-mix(in srgb, var(--surface) 84%, transparent);
}
.recovery-code-value {
@apply font-extrabold tracking-[0.08em];
}
.api-key-warning-panel,
.api-key-credentials-panel {
@apply rounded-lg p-3.5;
}
.api-key-warning-panel {
@apply mb-3.5 mt-3;
border: 1px solid color-mix(in srgb, var(--danger) 24%, transparent);
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
}
.api-key-warning-title {
@apply mb-2 font-extrabold;
color: var(--danger);
}
.api-key-warning-body {
@apply leading-[1.55];
color: var(--text);
}
.api-key-credentials-panel {
@apply mb-2.5;
border: 1px solid color-mix(in srgb, var(--primary) 25%, transparent);
background: color-mix(in srgb, var(--primary) 7%, var(--surface));
}
.api-key-credentials-title {
@apply mb-2.5 flex items-center gap-2 font-extrabold;
color: var(--primary);
}
.api-key-credential-row {
@apply grid grid-cols-[minmax(0,1fr)_auto] gap-2;
}
.create-menu-wrap {
@apply relative;
}
.create-menu {
@apply absolute left-0 z-20 w-[220px] overflow-hidden rounded-xl border;
top: calc(100% + 6px);
background: #fff;
border-color: var(--line);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
transform-origin: bottom left;
animation: menu-in 190ms var(--ease-out-strong) both;
}
.create-menu-item {
@apply flex w-full cursor-pointer items-center gap-2 border-0 px-3 py-[11px] text-left font-semibold;
border: none;
background: #fff;
transition:
background-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft);
}
.create-menu-item:hover {
background: #f1f5f9;
transform: translateX(2px);
}
.website-row {
@apply mb-2 grid items-center gap-2 rounded-[18px] border p-1.5;
grid-template-columns: auto minmax(0, 1fr) minmax(130px, 160px) auto;
border-color: transparent;
background: color-mix(in srgb, var(--panel) 84%, transparent);
transition:
border-color var(--dur-fast) var(--ease-smooth),
background-color var(--dur-fast) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-out-soft),
transform 220ms var(--ease-out-soft),
opacity var(--dur-fast) var(--ease-smooth);
}
.website-row.is-dragging {
@apply opacity-50;
border-color: rgba(37, 99, 235, 0.24);
background: color-mix(in srgb, var(--panel-soft) 92%, white 8%);
box-shadow: var(--shadow-sm);
}
.website-drag-btn {
@apply relative h-12 w-7 min-w-7 cursor-grab gap-0 overflow-visible rounded-[10px] p-0 opacity-[0.82];
color: var(--muted);
border-color: transparent;
background: transparent;
box-shadow: none;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.website-drag-btn:hover {
color: var(--primary-strong);
border-color: transparent;
background: transparent;
box-shadow: none;
opacity: 1;
}
.website-drag-btn:active {
cursor: grabbing;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.website-drag-btn::before {
content: '';
@apply absolute -inset-2 rounded-xl;
}
.website-drag-btn .btn-icon {
@apply opacity-90;
}
.website-match-select {
@apply h-12 py-2.5 pr-[38px] text-[13px] leading-[1.2];
}
.website-match-select option {
@apply text-[13px];
}
.website-row .btn {
@apply w-auto justify-self-start;
}
@media (max-width: 760px) {
.website-row {
grid-template-columns: auto minmax(0, 1fr) auto;
@apply items-start;
}
.website-row > :nth-child(1) {
grid-column: 1;
grid-row: 1;
align-self: center;
}
.website-row > :nth-child(2) {
grid-column: 2 / span 2;
grid-row: 1;
}
.website-row > :nth-child(3) {
grid-column: 1 / span 2;
grid-row: 2;
}
.website-row > :nth-child(4) {
grid-column: 3;
grid-row: 2;
justify-self: start;
}
}
.cf-check {
@apply mb-0;
}
.cf-check.view {
@apply m-0;
}
.cf-check input[type='checkbox'] {
@apply h-[22px] w-[22px];
}
.star-on {
background: #eef4ff;
}
.detail-actions {
@apply my-3 flex items-center justify-between;
}
.detail-delete-btn {
@apply ml-auto;
}
.send-options {
@apply grid gap-2;
color: #3a4a64;
}
.send-options label {
@apply inline-flex items-center gap-2;
}
.local-error {
@apply mt-2.5 font-semibold;
color: #b42318;
}
.status-ok {
@apply mb-2.5 mt-0.5 font-bold;
color: #0f766e;
}
.restore-progress-card {
@apply mb-3 mt-2 rounded-xl border px-4 py-3.5;
border-color: #d7e2f1;
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.10);
}
.restore-progress-overlay {
@apply fixed inset-0 z-[1250] grid place-items-center p-5;
background: rgba(15, 23, 42, 0.30);
}
.restore-progress-modal {
@apply m-0 w-[min(520px,100%)];
}
.restore-progress-head {
@apply mb-3 flex items-start justify-between gap-3;
}
.restore-progress-kicker {
@apply text-xs font-semibold;
letter-spacing: 0.02em;
color: #64748b;
}
.restore-progress-title {
@apply mb-0.5 mt-1 text-xl leading-[1.2];
}
.restore-progress-subtitle {
@apply m-0 text-[13px];
color: #6b7280;
}
.restore-progress-elapsed {
@apply shrink-0 rounded-[10px] px-2 py-1.5 text-center text-[13px] font-semibold;
min-width: 88px;
background: #f8fbff;
border: 1px solid #d7e2f1;
color: #475569;
}
.restore-progress-meter {
@apply h-1.5 overflow-hidden rounded-full;
background: #e7eef8;
}
.restore-progress-meter-bar {
@apply block h-full rounded-[inherit];
background: #3a71d8;
transition: width 280ms ease;
}
.restore-progress-current {
@apply mt-3 rounded-[10px] px-3 py-2.5;
background: #f8fbff;
border: 1px solid #d7e2f1;
}
.restore-progress-current strong {
@apply block text-sm;
color: #0f172a;
}
.restore-progress-current p {
@apply m-0 mt-1 text-[13px] leading-[1.45];
color: #64748b;
}
.restore-progress-list {
@apply m-0 mt-3 grid list-none gap-1.5 p-0;
}
.restore-progress-item {
@apply flex min-h-[30px] items-center gap-2 text-[13px] font-medium;
color: #64748b;
}
.restore-progress-item.active {
color: #1d4ed8;
}
.restore-progress-item.done {
color: #475569;
}
.restore-progress-dot {
@apply h-2 w-2 shrink-0 rounded-full;
background: #cbd5e1;
}
.restore-progress-item.active .restore-progress-dot {
background: #1d4ed8;
}
.restore-progress-item.done .restore-progress-dot {
background: #94a3b8;
}
.kv-line strong {
overflow-wrap: anywhere;
}
.check-line {
@apply mb-3 flex items-center gap-2;
color: #334155;
}
.table {
@apply w-full;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 10px 8px;
font-size: 14px;
}
.table td::before {
@apply hidden;
}
.table th {
color: #667085;
}
.input.small {
@apply w-[120px];
}
.invite-toolbar {
@apply mb-2.5 flex flex-wrap items-center justify-between gap-2.5;
}
.invite-create-group {
@apply items-end;
}
.invite-hours-field {
@apply m-0;
}
.invite-hours-field > span {
@apply mb-1.5 text-xs font-semibold;
color: #5f6f85;
}
.trusted-cell {
@apply inline-flex items-center gap-1.5;
}
.device-status-pill {
@apply inline-flex h-[26px] min-w-[58px] items-center justify-center rounded-full px-2.5 text-xs font-bold leading-none;
}
.device-status-pill.online {
background: #dcfce7;
color: #166534;
}
.device-status-pill.offline {
background: #e2e8f0;
color: #475569;
}
+120
View File
@@ -0,0 +1,120 @@
@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;
}
to {
opacity: 1;
}
}
@keyframes shell-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes surface-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes menu-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialog-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes toast-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes stagger-rise {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes dialog-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes route-stage-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
}
+226
View File
@@ -0,0 +1,226 @@
.dialog-mask {
@apply fixed inset-0 grid h-dvh w-screen place-items-center p-5 opacity-0;
background: rgba(15, 23, 42, 0.5);
z-index: 1200;
animation: fade-in var(--dur-medium) var(--ease-smooth) both;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.dialog-card {
@apply rounded-[20px] border bg-white p-5 text-center;
width: min(500px, 100%);
border: 1px solid var(--line);
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
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 {
@apply mb-2 flex items-center justify-center gap-3;
}
.dialog-warning-badge {
@apply inline-flex h-12 w-12 items-center justify-center rounded-2xl;
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 {
@apply text-xs font-extrabold uppercase tracking-[0.16em];
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 {
@apply text-left;
}
.dialog-title {
@apply my-1.5 text-3xl;
}
.dialog-message {
@apply mb-2.5;
color: #475467;
}
.dialog-card.warning .dialog-title {
@apply mb-2.5;
color: #7f1d1d;
}
.dialog-message.warning {
@apply mb-4 rounded-2xl px-4 py-3.5 leading-[1.65];
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;
box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset;
}
.dialog-btn {
@apply mt-2 h-[50px] w-full text-xl;
}
.dialog-extra {
@apply mt-2;
}
.dialog-divider {
@apply my-2 mb-2.5 h-px;
background: var(--line);
}
.import-summary-dialog {
@apply relative max-w-[520px] pt-4 text-left;
}
.import-summary-close {
@apply absolute right-2.5 top-2.5 cursor-pointer border-0 bg-transparent text-2xl leading-none text-slate-500;
}
.import-summary-close:hover {
color: #0f172a;
}
.import-summary-table-wrap {
@apply mt-2 overflow-hidden rounded-[10px];
border: 1px solid var(--line);
}
.import-summary-table {
@apply w-full text-sm;
border-collapse: collapse;
}
.import-summary-table th,
.import-summary-table td {
@apply px-3 py-2.5;
border-bottom: 1px solid var(--line);
}
.import-summary-table th {
@apply bg-slate-50 text-left;
color: #475467;
}
.import-summary-table td:last-child,
.import-summary-table th:last-child {
@apply w-24 text-right;
}
.import-summary-table tbody tr:last-child td {
border-bottom: none;
}
.import-summary-failed-list {
@apply mt-2.5 rounded-[10px] px-3 py-2.5 text-[13px];
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.import-summary-failed-title {
@apply mb-1.5 font-bold;
}
.import-summary-failed-list ul {
@apply m-0 pl-[18px];
}
.import-summary-failed-list li + li {
@apply mt-1;
}
.settings-twofactor-grid {
@apply grid gap-3;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-subcard {
@apply rounded-xl bg-white p-3;
border: 1px solid var(--line);
}
.settings-subcard h3 {
@apply mb-2.5 mt-0;
}
.toast-stack {
@apply fixed grid list-none gap-2.5 p-0;
top: 16px;
right: 16px;
z-index: 1400;
width: min(420px, calc(100vw - 20px));
margin: 0;
}
.toast-item {
@apply relative flex items-center justify-between overflow-hidden rounded-[10px] px-3.5 py-3;
border: 1px solid #bbdfc6;
background: #dff4e5;
color: #0f5132;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
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 {
@apply pr-2.5 font-bold;
}
.toast-close {
@apply cursor-pointer border-0 bg-transparent text-xl;
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 {
@apply absolute bottom-0 left-0 h-[3px] w-full;
background: rgba(15, 23, 42, 0.2);
animation: toast-life 4.5s linear forwards;
}
+689
View File
@@ -0,0 +1,689 @@
@media (max-width: 1180px) {
.app-page {
@apply p-2;
}
.app-shell {
@apply rounded-xl;
height: calc(100vh - 16px);
}
.app-main {
grid-template-columns: 1fr;
}
.app-side {
@apply grid items-start gap-2;
border-right: none;
border-bottom: 1px solid #d9e0ea;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-self: start;
height: fit-content;
}
.app-side > .side-link {
@apply min-h-0;
}
.vault-grid {
grid-template-columns: 1fr;
@apply h-auto;
}
.sidebar {
@apply max-h-[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 {
@apply text-2xl;
}
.standalone-footer {
@apply text-xs leading-[1.4];
}
}
@media (max-width: 1180px) {
.auth-page {
@apply items-start p-3.5;
}
.standalone-shell {
@apply w-full max-w-[460px] gap-2.5 pt-3;
}
.standalone-brand-outside {
@apply justify-start;
}
.standalone-brand-logo {
@apply h-11 w-11;
}
.auth-card {
@apply rounded-[18px] px-4 py-5;
}
.btn.full {
@apply h-12 text-lg;
}
.auth-support-row {
@apply flex-row items-center;
}
.app-page {
@apply p-0;
background: transparent;
}
.app-shell {
--mobile-topbar-height: 58px;
--mobile-tabbar-height: 70px;
@apply h-dvh max-w-none rounded-none border-0;
border: none;
box-shadow: none;
}
.topbar {
@apply relative z-20 px-3;
height: var(--mobile-topbar-height);
}
.brand {
@apply min-w-0 gap-2.5 text-lg;
}
.brand-logo {
@apply h-[34px] w-[34px];
}
.brand-wordmark {
@apply hidden;
}
.mobile-page-title {
@apply inline;
}
.topbar-actions .user-chip,
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn),
.topbar-actions > .theme-switch-wrap {
@apply hidden;
}
.mobile-sidebar-toggle,
.mobile-lock-btn {
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
}
.mobile-sidebar-toggle .btn-icon,
.mobile-lock-btn .btn-icon {
@apply m-0;
}
.mobile-theme-btn {
@apply inline-flex items-center;
}
.mobile-theme-btn .theme-switch {
transform: scale(0.8);
transform-origin: center;
}
.app-main {
@apply flex min-h-0 flex-col;
}
.app-side {
@apply hidden;
}
.content {
@apply min-h-0 flex-1;
-webkit-overflow-scrolling: touch;
}
.mobile-tabbar {
@apply grid items-center gap-1.5;
grid-template-columns: repeat(4, minmax(0, 1fr));
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 {
@apply grid justify-items-center gap-1 rounded-xl px-1 py-1.5 text-[11px] font-bold no-underline;
color: #64748b;
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: translateY(-1px);
}
.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 {
@apply gap-2.5 p-0;
}
.sidebar {
@apply hidden;
}
.mobile-sidebar-sheet {
@apply fixed left-2.5 right-2.5 z-[55] block overflow-auto rounded-[18px] p-3 opacity-0;
top: calc(var(--mobile-topbar-height) + 10px);
bottom: auto;
max-height: calc(100dvh - 145px);
border: 1px solid #d8dee8;
background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
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 {
@apply opacity-100;
visibility: visible;
pointer-events: auto;
transform: translate3d(0, 0, 0) scale(1);
}
.mobile-sidebar-head {
@apply mb-2.5 flex items-center justify-between gap-2.5;
}
.mobile-sidebar-title {
@apply text-base font-extrabold;
color: #0f172a;
}
.mobile-sidebar-close {
@apply inline-grid h-[34px] w-[34px] cursor-pointer place-items-center rounded-full p-0;
border: 1px solid #d7dde6;
background: #fff;
color: #0f172a;
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 {
@apply m-0 rounded-none border-0 p-0;
border: none;
background: transparent;
box-shadow: none;
}
.mobile-sidebar-sheet .tree-btn {
@apply mb-0.5;
}
.mobile-sidebar-sheet .folder-row {
@apply items-stretch gap-1;
}
.mobile-sidebar-sheet .folder-row .tree-btn {
@apply min-h-[42px];
}
.mobile-sidebar-sheet .sidebar-title,
.mobile-sidebar-sheet .sidebar-title-row {
@apply mb-0 pb-1.5;
}
.mobile-sidebar-sheet .tree-btn {
@apply rounded-[10px] px-2;
}
.mobile-sidebar-sheet .tree-btn.active {
background: #eef4ff;
}
.mobile-sidebar-sheet .folder-delete-btn {
@apply h-[42px] w-7 rounded-lg;
}
.list-col {
@apply max-w-none;
}
.list-head {
@apply grid items-center gap-2;
grid-template-columns: minmax(0, 1fr) auto auto auto;
}
.list-count {
grid-column: auto;
@apply w-auto whitespace-nowrap text-xs;
}
.list-head .search-input-wrap {
@apply w-full min-w-0;
}
.list-head .search-input {
@apply h-[42px] w-full min-w-0 rounded-[14px];
}
.list-icon-btn {
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
}
.toolbar.actions {
@apply justify-end overflow-visible pb-0.5;
flex-wrap: unset;
gap: var(--actions-gap);
}
.actions {
gap: var(--actions-gap);
}
.toolbar.actions .btn.small {
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
}
.mobile-fab-wrap {
@apply fixed right-3.5 z-[45];
bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
}
.mobile-fab-trigger {
@apply h-14 w-9 gap-0 rounded-full p-0 text-[0];
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
}
.mobile-fab-trigger .btn-icon {
@apply m-0 h-5 w-5;
}
.mobile-fab-wrap .create-menu {
left: auto;
right: 0;
top: auto;
bottom: calc(100% + 10px);
}
.list-panel {
@apply overflow-visible rounded-2xl;
}
.list-item {
@apply rounded-[14px] p-3;
}
.row-check {
@apply h-[18px] w-[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;
@apply hidden;
}
.mobile-detail-sheet {
@apply fixed left-0 right-0 z-[35] block overflow-auto opacity-0;
top: calc(var(--mobile-topbar-height) + env(safe-area-inset-top));
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
background: transparent;
padding: 0 0 18px;
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 {
@apply opacity-100;
visibility: visible;
pointer-events: auto;
transform: translate3d(0, 0, 0);
}
.mobile-panel-head {
@apply mb-2.5 ml-2.5 mr-2.5 flex items-center;
}
.mobile-panel-back {
@apply min-h-10;
}
.mobile-detail-sheet > .detail-switch-stage,
.mobile-detail-sheet > .card,
.mobile-detail-sheet > .empty {
@apply ml-2.5 mr-2.5;
}
.detail-col .card,
.import-export-panel,
.settings-subcard {
@apply rounded-2xl;
}
.card {
@apply p-3.5;
}
.section-head {
@apply flex-col items-start gap-2.5;
}
.detail-actions {
flex-direction: column;
align-items: stretch;
gap: 5px;
}
.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;
}
.settings-modules-grid,
.password-settings-grid {
grid-template-columns: 1fr;
}
.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: 1180px) {
.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);
}
}
+202
View File
@@ -0,0 +1,202 @@
.app-page {
@apply relative min-h-full bg-transparent p-5;
}
.app-shell {
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft shadow-elevated;
height: calc(100vh - 40px);
border-color: var(--line);
@apply rounded-3xl;
}
.topbar {
@apply flex h-[58px] items-center justify-between border-b px-[18px] text-slate-900 transition;
border-color: var(--line-soft);
background: rgba(244, 248, 255, 0.82);
}
.brand {
@apply inline-flex items-center gap-2 text-[34px] font-extrabold text-ink;
}
.brand-wordmark {
@apply block h-auto max-w-full;
width: clamp(210px, 20vw, 290px);
filter: drop-shadow(0 12px 24px rgba(43, 102, 217, 0.12));
}
.mobile-page-title {
@apply hidden min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[19px] font-extrabold leading-tight text-slate-900;
max-width: min(58vw, 240px);
}
.brand-logo {
@apply h-[42px] w-[42px] object-contain;
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.22));
}
.topbar-actions {
@apply flex items-center gap-2.5;
}
.mobile-tabbar {
@apply hidden;
}
.mobile-sidebar-toggle {
@apply hidden;
}
.mobile-lock-btn {
@apply hidden;
}
.mobile-theme-btn {
@apply hidden;
}
.theme-switch-wrap {
@apply inline-flex items-center justify-center;
}
.theme-switch {
@apply relative inline-block h-8 w-14;
}
.theme-switch-input {
@apply h-0 w-0 opacity-0;
}
.theme-switch-slider {
@apply absolute inset-0 cursor-pointer rounded-full border transition;
background: #dbeafe;
border-color: #9dbbec;
}
.theme-switch-slider::before {
@apply absolute h-[26px] w-[26px] rounded-full;
content: '';
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 {
@apply absolute h-[18px] w-[18px];
top: 6px;
left: 32px;
z-index: 1;
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;
@apply absolute h-4 w-4;
top: 7px;
left: 7px;
z-index: 1;
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: none;
}
.theme-switch:hover .sun svg,
.theme-switch:hover .moon svg {
transform: scale(1.08);
}
.topbar-actions .btn {
@apply h-[34px] rounded-xl px-3 text-[13px] font-semibold;
transition-duration: 220ms;
}
.topbar-actions .btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.user-chip {
@apply inline-flex h-[34px] items-center gap-1.5 rounded-full border px-3 text-sm font-semibold text-muted-strong transition;
background: rgba(249, 251, 255, 0.94);
border-color: rgba(148, 163, 184, 0.30);
box-shadow: 0 8px 16px rgba(13, 31, 68, 0.04);
}
.user-chip:hover {
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06);
}
.app-main {
@apply grid min-h-0 flex-1;
grid-template-columns: 200px 1fr;
}
.app-side {
@apply flex flex-col gap-2 border-r px-3 py-4;
border-color: var(--line-soft);
}
.side-link {
@apply flex items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-sm font-semibold text-muted-strong no-underline transition;
}
.side-link:hover {
background: #fff;
border-color: rgba(128, 152, 192, 0.18);
color: var(--text);
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04);
}
.side-link.active {
background: rgba(37, 99, 235, 0.11);
border-color: rgba(37, 99, 235, 0.28);
color: var(--primary-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58);
}
.content {
@apply min-h-0 overflow-hidden p-3.5;
}
.route-stage {
@apply h-full min-h-0 overflow-auto;
}
.mobile-sidebar-mask {
@apply pointer-events-none invisible fixed inset-0 opacity-0;
background: rgba(15, 23, 42, 0.36);
z-index: 54;
transition:
opacity 220ms var(--ease-smooth),
visibility 220ms var(--ease-smooth);
}
.mobile-sidebar-mask.open {
@apply pointer-events-auto visible opacity-100;
}
.mobile-sidebar-head {
@apply hidden;
}
+56
View File
@@ -0,0 +1,56 @@
:root {
--bg-accent: #eef3fa;
--panel: #ffffff;
--panel-soft: #f6f8fc;
--panel-muted: #edf2f8;
--panel-subtle: #f8fafc;
--line: rgba(113, 132, 163, 0.28);
--line-soft: rgba(113, 132, 163, 0.16);
--text: #0b1730;
--muted: #5f6f85;
--muted-strong: #2f4058;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-strong: #0f3f98;
--danger: #d92d57;
--success: #0f766e;
--warning: #b45309;
--overlay-strong: rgba(15, 23, 42, 0.56);
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-md: 0 8px 24px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 14px 38px rgba(15, 23, 42, 0.10);
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 18px;
--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(5px, calc((100vw - 520px) * 1), 10px);
}
:root[data-theme='dark'] {
--bg-accent: #0b1020;
--panel: #111827;
--panel-soft: #0f172a;
--panel-muted: #0b1324;
--panel-subtle: #151e2e;
--line: rgba(148, 163, 184, 0.20);
--line-soft: rgba(148, 163, 184, 0.12);
--text: #e5edf8;
--muted: #9aa8bb;
--muted-strong: #c7d2e2;
--primary: #8bb8ff;
--primary-hover: #a9ccff;
--primary-strong: #dceaff;
--danger: #fb7185;
--success: #5eead4;
--warning: #fbbf24;
--overlay-strong: rgba(2, 6, 23, 0.74);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.26);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.30);
--shadow-lg: 0 14px 38px rgba(0, 0, 0, 0.34);
}
+861
View File
@@ -0,0 +1,861 @@
.vault-grid {
@apply grid h-full min-h-0 gap-3 p-0.5;
grid-template-columns: 270px minmax(400px, 36%) minmax(400px, 1fr);
}
.sidebar,
.list-panel,
.card {
@apply rounded-2xl border bg-panel shadow-soft;
border-color: var(--line);
}
.sidebar {
@apply overflow-auto border-0 bg-transparent p-0 shadow-none;
}
.sidebar-block {
@apply mb-2 rounded-2xl border bg-panel p-3;
border-color: var(--line);
}
.sidebar-title {
@apply mb-2 text-[13px] font-bold text-slate-700;
}
.sidebar-title-row {
@apply flex items-center justify-between pb-2;
}
.sidebar-title-row .sidebar-title {
@apply mb-0;
}
.folder-title-actions {
@apply inline-flex items-center gap-2;
}
.folder-add-btn {
@apply inline-flex cursor-pointer items-center justify-center border-0 bg-transparent p-0 leading-none text-slate-700;
}
.folder-add-btn:hover {
color: #1d4ed8;
}
.search-input {
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-none transition;
background: var(--panel);
border-color: rgba(74, 103, 150, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
}
.search-input-wrap {
@apply relative min-w-0 flex-auto;
}
.search-input:focus {
border-color: rgba(43, 102, 217, 0.6);
background-color: #fbfdff;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.95);
}
.search-input-wrap .search-input {
@apply pr-[42px];
}
.search-clear-btn {
@apply absolute right-[9px] top-1/2 inline-flex h-[22px] w-[22px] cursor-pointer items-center justify-center rounded-full border-0;
border: none;
background: rgba(148, 163, 184, 0.18);
color: var(--muted);
transform: translateY(-50%);
transition: background-color var(--dur-fast) var(--ease-out-soft), color var(--dur-fast) var(--ease-out-soft), transform var(--dur-fast) var(--ease-out-soft);
}
.search-clear-btn:hover {
background: rgba(59, 130, 246, 0.18);
color: var(--brand);
transform: translateY(-50%) scale(1.04);
}
.search-clear-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
}
.tree-btn {
@apply mb-1 flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left transition;
}
.tree-btn:hover {
background: rgba(37, 99, 235, 0.05);
}
.tree-btn.active {
background: rgba(37, 99, 235, 0.09);
color: var(--primary-strong);
font-weight: 700;
}
.tree-icon {
@apply shrink-0;
}
.tree-label {
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
}
.folder-row {
@apply flex items-center gap-1.5;
}
.folder-row .tree-btn {
@apply mb-0;
}
.folder-delete-btn {
@apply inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0;
border: none;
color: #64748b;
transition:
color var(--dur-fast) var(--ease-smooth),
background-color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft);
}
.folder-delete-btn:hover {
color: #b91c1c;
background: #fee2e2;
transform: scale(1.06);
}
.folder-edit-btn:hover {
color: #1d4ed8;
background: #dbeafe;
}
.list-col {
@apply flex min-h-0 min-w-0 max-w-[540px] flex-col;
}
.toolbar {
@apply mb-2;
}
.toolbar.actions {
@apply justify-end;
gap: var(--actions-gap);
}
.toolbar .btn.small {
@apply h-[30px] rounded-full text-xs;
}
.list-head {
@apply mb-2 flex items-center gap-2.5;
}
.list-head .search-input-wrap {
@apply min-w-0 flex-auto;
}
.list-head .search-input {
@apply h-[42px];
}
.list-head .btn {
@apply whitespace-nowrap;
}
.list-count {
@apply shrink-0 whitespace-nowrap text-xs;
color: var(--text-muted);
}
.list-icon-btn {
@apply whitespace-nowrap;
}
.sort-menu-wrap {
@apply relative shrink-0;
}
.sort-trigger {
@apply w-9 min-w-9 justify-center gap-0 p-0;
}
.sort-trigger.active {
background: #e9f1ff;
border-color: #a9c2ee;
color: #175ddc;
}
.sort-menu {
@apply absolute right-0 z-30 min-w-[156px] rounded-2xl border p-1.5;
top: calc(100% + 6px);
background: var(--panel);
border-color: var(--line);
box-shadow: var(--shadow-md);
transform-origin: top right;
animation: menu-in 190ms var(--ease-out-strong) both;
}
.sort-menu-item {
@apply flex w-full cursor-pointer items-center justify-between gap-2.5 rounded-[10px] border-0 bg-transparent px-2.5 py-[9px] text-left text-[13px];
border: none;
color: #0f172a;
transition:
background-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft);
}
.sort-menu-item:hover {
background: #f2f7ff;
transform: translateX(2px);
}
.sort-menu-item.active {
@apply font-bold;
background: rgba(37, 99, 235, 0.1);
color: var(--primary-strong);
}
.sort-menu-check-placeholder {
@apply h-3.5 w-3.5 shrink-0;
}
.list-panel {
@apply min-h-0 overflow-auto p-2;
}
.list-item {
@apply relative mb-2 flex min-h-[66px] w-full cursor-pointer items-center gap-2.5 overflow-hidden rounded-xl border px-3 py-2.5 transition;
background: rgba(249, 251, 255, 0.9);
border-color: var(--line);
contain: paint;
}
.list-item::before {
content: '';
@apply absolute inset-0 opacity-0;
background:
linear-gradient(90deg, rgba(43, 102, 217, 0.06), transparent 24%, transparent 76%, rgba(14, 165, 233, 0.05)),
radial-gradient(circle at 18px 50%, rgba(255, 255, 255, 0.28), transparent 44%);
transition:
opacity var(--dur-fast) var(--ease-smooth),
transform 320ms var(--ease-out-soft);
transform: translateX(-8px);
pointer-events: none;
}
.stagger-item {
opacity: 0;
animation: stagger-rise 520ms var(--ease-out-strong) both;
}
.stagger-delay-0 {
@apply [animation-delay:0ms];
}
.stagger-delay-1 {
@apply [animation-delay:26ms];
}
.stagger-delay-2 {
@apply [animation-delay:52ms];
}
.stagger-delay-3 {
@apply [animation-delay:78ms];
}
.stagger-delay-4 {
@apply [animation-delay:104ms];
}
.stagger-delay-5 {
@apply [animation-delay:130ms];
}
.stagger-delay-6 {
@apply [animation-delay:156ms];
}
.stagger-delay-7 {
@apply [animation-delay:182ms];
}
.stagger-delay-8 {
@apply [animation-delay:208ms];
}
.stagger-delay-9 {
@apply [animation-delay:234ms];
}
.stagger-delay-10 {
@apply [animation-delay:260ms];
}
.detail-unlock-actions {
@apply mt-2.5;
}
.archive-badge {
@apply mt-2 w-fit;
}
.passkeys-section-head {
@apply mt-[18px];
}
.list-item:hover {
background: #fcfdff;
border-color: rgba(148, 163, 184, 0.26);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.05);
}
.list-item:hover::before {
opacity: 1;
transform: translateX(0);
}
.list-item.active {
background: rgba(37, 99, 235, 0.1);
border-color: rgba(43, 102, 217, 0.26);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
}
.list-item.active::before {
opacity: 1;
transform: translateX(0);
}
.row-check {
@apply relative z-[2] h-4 w-4 cursor-pointer;
}
.row-main {
@apply relative z-[1] flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 border-0 bg-transparent p-0 text-left;
border: none;
transition: transform 220ms var(--ease-out-soft);
}
.list-item:hover .row-main,
.list-item.active .row-main {
transform: translateX(2px);
}
.list-icon-wrap {
@apply grid h-6 w-6 shrink-0 place-items-center;
transition: transform 240ms var(--ease-out-soft), filter 240ms var(--ease-out-soft);
}
.list-icon {
@apply h-6 w-6 rounded-md;
opacity: 0;
transition: opacity var(--dur-fast) var(--ease-smooth);
}
.list-icon-stack {
@apply grid h-6 w-6 place-items-center;
}
.list-icon-stack > .list-icon,
.list-icon-stack > .list-icon-fallback {
grid-area: 1 / 1;
}
.list-icon-stack > .list-icon {
z-index: 2;
}
.list-icon-stack > .list-icon.loaded {
opacity: 1;
}
.list-icon-stack > .list-icon-fallback {
opacity: 1;
z-index: 1;
}
.list-icon-stack > .list-icon-fallback.hidden {
opacity: 0;
}
.list-icon-fallback {
@apply grid place-items-center;
color: #64748b;
transition:
color var(--dur-fast) var(--ease-smooth),
opacity var(--dur-fast) var(--ease-smooth),
transform 240ms var(--ease-out-soft);
}
.list-icon-fallback svg {
@apply h-6 w-6;
}
.list-text {
@apply min-w-0 flex-1 overflow-hidden;
transition: transform 220ms var(--ease-out-soft);
}
.list-title {
@apply flex min-w-0 items-center gap-1.5 text-[15px] font-bold;
color: var(--primary-strong);
transition: color var(--dur-fast) var(--ease-smooth), letter-spacing 220ms var(--ease-out-soft);
}
.list-title-text {
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
}
.list-badge {
@apply inline-flex shrink-0 items-center justify-center rounded-full px-1.5 py-0.5 text-[11px] font-bold leading-none;
color: #475569;
background: #e8eef8;
}
.list-badge.danger {
color: #fff;
background: var(--danger);
}
.list-sub {
@apply mt-0.5 block overflow-hidden text-ellipsis whitespace-nowrap text-[13px];
color: #5f6f85;
transition: color var(--dur-fast) var(--ease-smooth), transform 220ms var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.list-item:hover .list-icon-wrap,
.list-item.active .list-icon-wrap {
transform: translateX(1px) scale(1.04);
}
.list-item:hover .list-icon-fallback,
.list-item.active .list-icon-fallback {
color: var(--primary-strong);
transform: scale(1.04);
}
.list-item:hover .list-text,
.list-item.active .list-text {
transform: translateX(1px);
}
.list-item:hover .list-title,
.list-item.active .list-title {
letter-spacing: -0.012em;
}
.list-item:hover .list-sub,
.list-item.active .list-sub {
transform: translateX(1px);
opacity: 0.92;
}
.detail-col {
@apply min-h-0 overflow-auto;
}
.mobile-panel-head {
@apply hidden;
}
.card {
@apply mb-2.5 px-[18px] py-4;
}
.detail-col > .card {
@apply opacity-100;
animation: none;
}
.detail-col > .card:nth-of-type(1) { animation-delay: 0ms; }
.detail-col > .card:nth-of-type(2) { animation-delay: 0ms; }
.detail-col > .card:nth-of-type(3) { animation-delay: 0ms; }
.detail-col > .card:nth-of-type(4) { animation-delay: 0ms; }
.detail-col > .card:nth-of-type(5) { animation-delay: 0ms; }
.detail-switch-stage {
animation: none;
}
.detail-switch-stage > .card {
@apply opacity-100;
animation: none;
}
.card h4 {
@apply mb-3 mt-0;
}
.detail-title {
@apply m-0 overflow-hidden text-ellipsis whitespace-nowrap;
}
.detail-sub {
@apply mt-2;
color: #667085;
}
.password-history-link {
@apply mt-2.5 cursor-pointer p-0 font-bold;
border: none;
background: transparent;
color: var(--primary);
font: inherit;
}
.password-history-link:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.kv-line {
@apply flex items-center justify-between gap-2.5 py-2.5;
border-bottom: 1px solid rgba(154, 172, 205, 0.22);
}
.kv-line:last-child {
border-bottom: none;
}
.kv-line > span {
color: #64748b;
}
.kv-row {
@apply grid items-center gap-2.5 py-2.5;
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto;
border-bottom: 1px solid rgba(154, 172, 205, 0.22);
}
.kv-row:last-child {
border-bottom: none;
}
.password-history-dialog {
@apply w-[min(560px,calc(100vw-32px))];
}
.password-history-head {
@apply mb-3 flex items-center justify-between gap-3;
}
.password-history-head .dialog-title {
@apply m-0;
}
.password-history-close {
@apply inline-flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-full border-0;
border: none;
background: transparent;
color: var(--muted-strong);
}
.password-history-close:hover {
background: var(--panel-soft);
color: var(--text);
}
.password-history-list {
@apply mb-[18px] mt-2.5 grid gap-3;
}
.password-history-item {
@apply relative rounded-[14px] border py-4 pl-4 pr-[54px];
border-color: var(--line);
background: var(--panel-soft);
box-shadow: var(--shadow-sm);
}
.password-history-value {
@apply text-[22px] leading-[1.15];
color: var(--primary);
letter-spacing: 0.01em;
word-break: break-all;
}
.password-history-time {
@apply mt-2;
color: var(--muted);
}
.password-history-copy {
@apply absolute right-3 top-3;
}
.password-history-copy-btn {
@apply h-9 w-9 min-w-9 p-0;
}
.kv-label {
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
color: #64748b;
}
.kv-main {
@apply flex min-w-0 items-center justify-start gap-2.5;
}
.kv-main > strong {
@apply min-w-0;
}
.totp-inline {
@apply inline-flex min-w-0 items-center gap-2.5;
}
.totp-timer {
@apply relative inline-grid h-[30px] w-[30px] shrink-0 place-items-center;
}
.totp-ring {
@apply h-[30px] w-[30px];
transform: rotate(-90deg);
}
.totp-ring-track,
.totp-ring-progress {
fill: none;
stroke-width: 3;
}
.totp-ring-track {
stroke: #d9e2ef;
}
.totp-ring-progress {
stroke: #2563eb;
stroke-linecap: round;
transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
}
.totp-timer-value {
@apply absolute inset-0 grid place-items-center text-[11px] font-bold;
color: #0f172a;
}
.totp-codes-page {
@apply flex min-h-full flex-col;
}
.totp-codes-list {
@apply grid w-full items-start gap-2.5;
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr));
}
.totp-code-row {
@apply grid w-full min-w-0 max-w-none items-center gap-2.5 rounded-xl border p-3;
grid-template-columns: auto minmax(0, 1fr) auto;
border-color: #e2e8f0;
background: #f8fafc;
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),
opacity var(--dur-fast) var(--ease-smooth);
}
.totp-code-row.is-dragging {
@apply z-[2];
border-color: rgba(37, 99, 235, 0.3);
background: color-mix(in srgb, var(--panel) 88%, white 12%);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14);
}
.totp-code-info {
@apply flex min-w-0 items-center gap-2.5;
}
.totp-drag-btn {
@apply relative h-[34px] w-6 min-w-6 cursor-grab self-center overflow-visible rounded-[10px] p-0 opacity-[0.82];
color: var(--muted);
touch-action: none;
-webkit-user-select: none;
user-select: none;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.totp-drag-btn:hover {
color: var(--primary-strong);
border-color: transparent;
background: transparent;
box-shadow: none;
opacity: 1;
}
.totp-drag-btn:active {
cursor: grabbing;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.totp-drag-btn::before {
content: '';
@apply absolute -inset-2.5 rounded-xl;
}
.totp-drag-btn .btn-icon {
@apply opacity-90;
}
.totp-code-main {
@apply flex min-w-0 shrink-0 items-center gap-1.5;
}
.totp-code-main strong {
@apply whitespace-nowrap text-[22px] leading-none;
letter-spacing: 0.04em;
}
.totp-code-meta {
@apply min-w-0;
}
.totp-code-name,
.totp-code-username {
@apply overflow-hidden text-ellipsis whitespace-nowrap;
}
.totp-code-name {
@apply text-[15px] font-bold;
color: #0f172a;
}
.totp-code-username {
@apply mt-0.5 text-[13px];
color: #64748b;
}
.totp-copy-btn {
@apply h-7 w-7 min-w-7 shrink-0 gap-0 rounded-full p-0;
}
.value-ellipsis {
@apply block max-w-full overflow-hidden text-ellipsis whitespace-nowrap;
}
.kv-actions {
@apply flex shrink-0 flex-wrap items-center justify-end gap-2;
}
.attachment-list {
@apply grid gap-0;
}
.attachment-head {
@apply mb-2;
}
.attachment-head h4 {
@apply mb-0;
}
.attachment-add-btn {
@apply min-w-8 px-2 py-0;
}
.attachment-file-input {
@apply hidden;
}
.attachment-row {
@apply flex items-center justify-between gap-2.5 py-2.5;
border-bottom: 1px solid #ecf0f5;
}
.attachment-row:last-child {
border-bottom: none;
}
.attachment-main {
@apply flex min-w-0 items-center gap-2;
}
.attachment-text {
@apply grid min-w-0 gap-0.5;
}
.attachment-text span {
@apply text-xs;
color: #64748b;
}
.attachment-row.is-removed {
@apply opacity-60;
}
.attachment-row.is-removed .attachment-text strong {
text-decoration: line-through;
}
.attachment-queue-title {
@apply px-0 pb-0.5 pt-2 text-xs font-bold;
color: #64748b;
}
.boolean-text {
@apply min-w-0;
}
.custom-field-card {
@apply grid gap-2 py-2.5;
border-bottom: 1px solid #ecf0f5;
}
.custom-field-card:last-child {
border-bottom: none;
padding-bottom: 0;
}
.custom-field-label {
@apply block overflow-hidden text-ellipsis whitespace-nowrap text-xs font-bold leading-[1.2];
color: #64748b;
}
.custom-field-body {
@apply grid items-center gap-2.5;
grid-template-columns: minmax(0, 1fr) auto;
}
.custom-field-value {
@apply min-w-0;
}
.custom-field-value > .input {
@apply w-full;
}
.custom-field-check {
@apply mb-0 inline-flex items-center gap-2;
}
.custom-field-check span {
@apply text-sm font-semibold;
color: #334155;
}
.custom-field-remove {
@apply whitespace-nowrap;
}
.notes {
@apply min-h-12 whitespace-pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
color: #334155;
}
.empty {
@apply grid min-h-[120px] place-items-center;
color: #667085;
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;