Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15ee922777 | |||
| 2ea0b2c14c | |||
| 4ec1926888 | |||
| 3995e01336 | |||
| 481536ba24 | |||
| db8b9263a1 | |||
| a1f7250e90 | |||
| e4bc1b9bbe | |||
| 514889adfc | |||
| fccc85c4bb | |||
| acd59a7387 | |||
| d40b0514fd | |||
| 033d44808f | |||
| 4246e179f1 | |||
| fe8d9e0b7d | |||
| 1147c1e013 | |||
| 31ffd98166 | |||
| 7d7562d191 | |||
| d6e5a1c40b | |||
| 77794e43ce | |||
| b990f17a3e | |||
| 31b8ec6f7d | |||
| ef47597be5 | |||
| 408874ac05 | |||
| dabd2c923e | |||
| 08414d7cf2 | |||
| 38b33df719 | |||
| 7ebd12fa07 | |||
| f7cbdaf730 |
@@ -42,3 +42,4 @@ tmp/
|
||||
.tmp/
|
||||
|
||||
nodewarden.wiki/
|
||||
AGENTS.md
|
||||
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 472 KiB |
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
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
|
||||
);
|
||||
@@ -153,12 +154,15 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT,
|
||||
device_note TEXT,
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_identifier),
|
||||
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_last_seen ON devices(user_id, last_seen_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.6",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -40,6 +40,9 @@
|
||||
"@cloudflare/workers-types": "^4.20260131.0",
|
||||
"@preact/preset-vite": "^2.10.3",
|
||||
"@types/node": "^25.2.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.4.3';
|
||||
export const APP_VERSION = '1.4.6';
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||
defaultKdfIterations: 600000,
|
||||
// clientSecret length
|
||||
// clientSecret 长度
|
||||
clientSecretLength: 30,
|
||||
},
|
||||
rateLimit: {
|
||||
// Max failed login attempts before temporary lock.
|
||||
|
||||
@@ -209,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
verifyDevices: true,
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
apiKey: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -751,3 +752,68 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// Public download endpoint (uses token for auth instead of header)
|
||||
export async function handlePublicDownloadAttachment(
|
||||
|
||||
@@ -218,26 +218,28 @@ async function executeConfiguredBackup(
|
||||
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
||||
});
|
||||
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||
let attachmentIndexChanged = false;
|
||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||
continue;
|
||||
if (destination.includeAttachments) {
|
||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||
let attachmentIndexChanged = false;
|
||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||
continue;
|
||||
}
|
||||
const remotePath = `attachments/${attachment.blobName}`;
|
||||
const object = await getBlobObject(env, attachment.blobName);
|
||||
if (!object) {
|
||||
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
||||
}
|
||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||
await remoteSession.putFile(remotePath, bytes, {
|
||||
contentType: object.contentType,
|
||||
});
|
||||
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||
attachmentIndexChanged = true;
|
||||
}
|
||||
const remotePath = `attachments/${attachment.blobName}`;
|
||||
const object = await getBlobObject(env, attachment.blobName);
|
||||
if (!object) {
|
||||
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
||||
if (attachmentIndexChanged) {
|
||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||
}
|
||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||
await remoteSession.putFile(remotePath, bytes, {
|
||||
contentType: object.contentType,
|
||||
});
|
||||
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||
attachmentIndexChanged = true;
|
||||
}
|
||||
if (attachmentIndexChanged) {
|
||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||
}
|
||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||
|
||||
@@ -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 { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
@@ -13,26 +24,6 @@ function normalizeOptionalId(value: unknown): string | 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(
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -52,6 +43,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
||||
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 {
|
||||
if (value == null || value === '') return null;
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
cipher.archivedDate = cipher.archivedAt ?? null;
|
||||
cipher.deletedDate = cipher.deletedAt ?? null;
|
||||
@@ -151,8 +159,8 @@ export function cipherToResponse(
|
||||
// Server-computed / enforced fields (always override)
|
||||
folderId: normalizeOptionalId(cipher.folderId),
|
||||
type: Number(cipher.type) || 1,
|
||||
organizationId: null,
|
||||
organizationUseTotp: false,
|
||||
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||
creationDate: createdAt,
|
||||
revisionDate: updatedAt,
|
||||
deletedDate: deletedAt,
|
||||
@@ -163,12 +171,12 @@ export function cipherToResponse(
|
||||
delete: true,
|
||||
restore: true,
|
||||
},
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
object: 'cipherDetails',
|
||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||
attachments: formatAttachments(attachments),
|
||||
login: normalizedLogin,
|
||||
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)
|
||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||
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();
|
||||
// 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),
|
||||
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']);
|
||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||
normalizeCipherForStorage(cipher);
|
||||
@@ -307,6 +331,21 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
// Handle nested cipher object
|
||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||
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.
|
||||
// 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)
|
||||
id: existingCipher.id,
|
||||
userId: existingCipher.userId,
|
||||
type: Number(cipherData.type) || existingCipher.type,
|
||||
type: nextType,
|
||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
createdAt: existingCipher.createdAt,
|
||||
@@ -324,11 +363,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
cipher.login = mergeCipherNestedObject(existingCipher.login, cipherData.login);
|
||||
cipher.card = mergeCipherNestedObject(existingCipher.card, cipherData.card);
|
||||
cipher.identity = mergeCipherNestedObject(existingCipher.identity, cipherData.identity);
|
||||
cipher.secureNote = mergeCipherNestedObject(existingCipher.secureNote, cipherData.secureNote);
|
||||
cipher.sshKey = mergeCipherNestedObject(existingCipher.sshKey, cipherData.sshKey);
|
||||
if (incomingFolderId.present) {
|
||||
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
||||
}
|
||||
if (incomingKey.present) {
|
||||
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:
|
||||
// - 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);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
|
||||
}
|
||||
|
||||
function buildDeviceResponse(device: Device): DeviceResponse {
|
||||
const displayName = String(device.deviceNote || '').trim() || device.name;
|
||||
const response = {
|
||||
Id: device.deviceIdentifier,
|
||||
id: device.deviceIdentifier,
|
||||
UserId: device.userId,
|
||||
userId: device.userId,
|
||||
Name: device.name,
|
||||
name: device.name,
|
||||
Name: displayName,
|
||||
name: displayName,
|
||||
SystemName: device.name,
|
||||
systemName: device.name,
|
||||
DeviceNote: device.deviceNote,
|
||||
deviceNote: device.deviceNote,
|
||||
Identifier: device.deviceIdentifier,
|
||||
identifier: device.deviceIdentifier,
|
||||
Type: device.type,
|
||||
@@ -38,6 +43,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
|
||||
creationDate: device.createdAt,
|
||||
RevisionDate: device.updatedAt,
|
||||
revisionDate: device.updatedAt,
|
||||
LastSeenAt: device.lastSeenAt,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
HasStoredDevice: true,
|
||||
hasStoredDevice: true,
|
||||
IsTrusted: isTrustedDevice(device),
|
||||
isTrusted: isTrustedDevice(device),
|
||||
EncryptedUserKey: device.encryptedUserKey,
|
||||
@@ -55,8 +64,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
|
||||
const response = {
|
||||
Id: device.deviceIdentifier,
|
||||
id: device.deviceIdentifier,
|
||||
Name: device.name,
|
||||
name: device.name,
|
||||
Name: String(device.deviceNote || '').trim() || 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,
|
||||
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
|
||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||
// - X-Request-Email: base64url(email) without padding
|
||||
@@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
devicePendingAuthRequest: null,
|
||||
deviceNote: null,
|
||||
lastSeenAt: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
data.push({
|
||||
...buildDeviceResponse(placeholderDevice),
|
||||
isTrusted: true,
|
||||
hasStoredDevice: false,
|
||||
online: onlineSet.has(row.deviceIdentifier),
|
||||
trusted: true,
|
||||
trustedTokenCount: row.tokenCount,
|
||||
@@ -269,6 +289,29 @@ export async function handleDeleteDevice(
|
||||
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
|
||||
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
|
||||
@@ -48,6 +48,18 @@ function parseCookieValue(request: Request, name: string): string | 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 {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
@@ -361,6 +373,98 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: 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') {
|
||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||
if (!sendAccessLimit.allowed) {
|
||||
@@ -450,6 +554,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
);
|
||||
|
||||
const { accessToken, user, device } = result;
|
||||
if (device?.identifier) {
|
||||
await storage.touchDeviceLastSeen(user.id, device.identifier);
|
||||
}
|
||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
@@ -550,3 +657,16 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
||||
? withWebRefreshCookie(request, baseResponse, null)
|
||||
: 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;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,16 @@ function bindNull(v: any): any {
|
||||
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> {
|
||||
for (let i = 0; i < statements.length; 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 }> = [];
|
||||
for (let i = 0; i < ciphers.length; 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 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 = {
|
||||
...c,
|
||||
@@ -171,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
name: c.name ?? 'Untitled',
|
||||
notes: c.notes ?? null,
|
||||
favorite: c.favorite ?? false,
|
||||
login: c.login ? {
|
||||
...c.login,
|
||||
username: c.login.username ?? null,
|
||||
password: c.login.password ?? null,
|
||||
uris: c.login.uris?.map(u => ({
|
||||
login: login ? {
|
||||
...login,
|
||||
username: login.username ?? null,
|
||||
password: login.password ?? null,
|
||||
uris: login.uris?.map((u: any) => ({
|
||||
...u,
|
||||
uri: u.uri ?? null,
|
||||
uriChecksum: null,
|
||||
match: u.match ?? null,
|
||||
})) || null,
|
||||
totp: c.login.totp ?? null,
|
||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||
fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null,
|
||||
uri: c.login.uri ?? null,
|
||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
||||
totp: login.totp ?? null,
|
||||
autofillOnPageLoad: login.autofillOnPageLoad ?? null,
|
||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||
uri: login.uri ?? null,
|
||||
passwordRevisionDate: login.passwordRevisionDate ?? null,
|
||||
} : null,
|
||||
card: c.card ? {
|
||||
...c.card,
|
||||
cardholderName: c.card.cardholderName ?? null,
|
||||
brand: c.card.brand ?? null,
|
||||
number: c.card.number ?? null,
|
||||
expMonth: c.card.expMonth ?? null,
|
||||
expYear: c.card.expYear ?? null,
|
||||
code: c.card.code ?? null,
|
||||
card: card ? {
|
||||
...card,
|
||||
cardholderName: card.cardholderName ?? null,
|
||||
brand: card.brand ?? null,
|
||||
number: card.number ?? null,
|
||||
expMonth: card.expMonth ?? null,
|
||||
expYear: card.expYear ?? null,
|
||||
code: card.code ?? null,
|
||||
} : null,
|
||||
identity: c.identity ? {
|
||||
...c.identity,
|
||||
title: c.identity.title ?? null,
|
||||
firstName: c.identity.firstName ?? null,
|
||||
middleName: c.identity.middleName ?? null,
|
||||
lastName: c.identity.lastName ?? null,
|
||||
address1: c.identity.address1 ?? null,
|
||||
address2: c.identity.address2 ?? null,
|
||||
address3: c.identity.address3 ?? null,
|
||||
city: c.identity.city ?? null,
|
||||
state: c.identity.state ?? null,
|
||||
postalCode: c.identity.postalCode ?? null,
|
||||
country: c.identity.country ?? null,
|
||||
company: c.identity.company ?? null,
|
||||
email: c.identity.email ?? null,
|
||||
phone: c.identity.phone ?? null,
|
||||
ssn: c.identity.ssn ?? null,
|
||||
username: c.identity.username ?? null,
|
||||
passportNumber: c.identity.passportNumber ?? null,
|
||||
licenseNumber: c.identity.licenseNumber ?? null,
|
||||
identity: identity ? {
|
||||
...identity,
|
||||
title: identity.title ?? null,
|
||||
firstName: identity.firstName ?? null,
|
||||
middleName: identity.middleName ?? null,
|
||||
lastName: identity.lastName ?? null,
|
||||
address1: identity.address1 ?? null,
|
||||
address2: identity.address2 ?? null,
|
||||
address3: identity.address3 ?? null,
|
||||
city: identity.city ?? null,
|
||||
state: identity.state ?? null,
|
||||
postalCode: identity.postalCode ?? null,
|
||||
country: identity.country ?? null,
|
||||
company: identity.company ?? null,
|
||||
email: identity.email ?? null,
|
||||
phone: identity.phone ?? null,
|
||||
ssn: identity.ssn ?? null,
|
||||
username: identity.username ?? null,
|
||||
passportNumber: identity.passportNumber ?? null,
|
||||
licenseNumber: identity.licenseNumber ?? null,
|
||||
} : null,
|
||||
secureNote: c.secureNote ?? null,
|
||||
fields: c.fields?.map(f => ({
|
||||
secureNote: secureNote ?? null,
|
||||
fields: fields?.map((f: any) => ({
|
||||
...f,
|
||||
name: f.name ?? null,
|
||||
value: f.value ?? null,
|
||||
type: f.type,
|
||||
linkedId: f.linkedId ?? null,
|
||||
})) || null,
|
||||
passwordHistory: c.passwordHistory ?? null,
|
||||
passwordHistory: passwordHistory ?? null,
|
||||
reprompt: c.reprompt ?? 0,
|
||||
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||
key: (c as any).key ?? null,
|
||||
key: key ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleGetApiKey,
|
||||
handleRotateApiKey,
|
||||
} from './handlers/accounts';
|
||||
import {
|
||||
handleGetCiphers,
|
||||
@@ -58,6 +60,7 @@ import {
|
||||
handleCreateAttachment,
|
||||
handleUploadAttachment,
|
||||
handleGetAttachment,
|
||||
handleUpdateAttachmentMetadata,
|
||||
handleDeleteAttachment,
|
||||
} from './handlers/attachments';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
@@ -119,6 +122,14 @@ export async function handleAuthenticatedRoute(
|
||||
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') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
@@ -191,6 +202,11 @@ export async function handleAuthenticatedRoute(
|
||||
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);
|
||||
if (attachmentDeleteMatch && method === 'POST') {
|
||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
handleRevokeTrustedDevice,
|
||||
handleDeleteAllDevices,
|
||||
handleDeleteDevice,
|
||||
handleUpdateDeviceName,
|
||||
handleUpdateDeviceToken,
|
||||
handleUpdateDeviceWebPushAuth,
|
||||
handleClearDeviceToken,
|
||||
@@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute(
|
||||
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);
|
||||
if (identifierMatch && method === 'GET') {
|
||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||
|
||||
@@ -52,12 +52,12 @@ function isSameOriginWriteRequest(request: Request): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNwIconSvg(): 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>`;
|
||||
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="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 {
|
||||
return new Response(getNwIconSvg(), {
|
||||
return new Response(getDefaultWebsiteIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'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 {
|
||||
return `${origin}/icons`;
|
||||
}
|
||||
@@ -104,6 +113,7 @@ function buildConfigResponse(origin: string) {
|
||||
_icon_service_url: buildIconServiceTemplate(origin),
|
||||
_icon_service_csp: buildIconServiceCsp(origin),
|
||||
featureStates: {
|
||||
'cipher-key-encryption': true,
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
@@ -116,7 +126,12 @@ function buildConfigResponse(origin: string) {
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
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);
|
||||
if (!normalizedHost) return handleNwFavicon();
|
||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
|
||||
const encodedHost = encodeURIComponent(normalizedHost);
|
||||
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 {
|
||||
return handleNwFavicon();
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +235,8 @@ export async function handlePublicRoute(
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
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);
|
||||
|
||||
@@ -347,7 +347,7 @@ export async function buildBackupArchive(
|
||||
const encoder = new TextEncoder();
|
||||
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 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 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'),
|
||||
|
||||
@@ -594,7 +594,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
buildInsertStatements(
|
||||
db,
|
||||
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 || []
|
||||
)
|
||||
);
|
||||
|
||||
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
|
||||
userId: row.user_id,
|
||||
deviceIdentifier: row.device_identifier,
|
||||
name: row.name,
|
||||
deviceNote: row.device_note ?? null,
|
||||
type: row.type,
|
||||
sessionStamp: row.session_stamp || '',
|
||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||
lastSeenAt: row.last_seen_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -33,31 +35,62 @@ export async function upsertDevice(
|
||||
}
|
||||
): Promise<void> {
|
||||
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
|
||||
.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, ' +
|
||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||
'last_seen_at=excluded.last_seen_at, ' +
|
||||
'updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(
|
||||
userId,
|
||||
deviceIdentifier,
|
||||
name,
|
||||
effectiveName,
|
||||
type,
|
||||
effectiveSessionStamp,
|
||||
keys?.encryptedUserKey ?? null,
|
||||
keys?.encryptedPublicKey ?? null,
|
||||
keys?.encryptedPrivateKey ?? null,
|
||||
existingDevice?.deviceNote ?? null,
|
||||
now,
|
||||
now,
|
||||
now
|
||||
)
|
||||
.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(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
|
||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||
const res = await db
|
||||
.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 ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
'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 COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.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> {
|
||||
const row = await db
|
||||
.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'
|
||||
)
|
||||
.bind(userId, deviceIdentifier)
|
||||
|
||||
@@ -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, ' +
|
||||
'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, ' +
|
||||
'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 role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'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 totp_secret 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 (' +
|
||||
'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 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, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'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 banned INTEGER NOT NULL DEFAULT 0',
|
||||
'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 (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
|
||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
||||
const USER_SELECT_COLUMNS =
|
||||
'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';
|
||||
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
return {
|
||||
@@ -26,6 +26,7 @@ function mapUserRow(row: any): User {
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
apiKey: row.api_key ?? null,
|
||||
createdAt: row.created_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> {
|
||||
const email = user.email.toLowerCase();
|
||||
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) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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, ' +
|
||||
'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(
|
||||
stmt,
|
||||
@@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).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> {
|
||||
const email = user.email.toLowerCase();
|
||||
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) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
|
||||
@@ -92,7 +92,9 @@ import {
|
||||
isKnownDevice as getKnownStoredDevice,
|
||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||
upsertDevice as saveStoredDevice,
|
||||
updateDeviceName as updateStoredDeviceName,
|
||||
updateDeviceKeys as updateStoredDeviceKeys,
|
||||
} from './storage-device-repo';
|
||||
import {
|
||||
@@ -106,7 +108,7 @@ import {
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
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.
|
||||
// Contract:
|
||||
@@ -550,6 +552,14 @@ export class StorageService {
|
||||
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> {
|
||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface User {
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
apiKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -189,12 +190,14 @@ export interface Device {
|
||||
userId: string;
|
||||
deviceIdentifier: string;
|
||||
name: string;
|
||||
deviceNote: string | null;
|
||||
type: number;
|
||||
sessionStamp: string;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -208,10 +211,14 @@ export interface DeviceResponse {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
name: string;
|
||||
systemName?: string | null;
|
||||
deviceNote?: string | null;
|
||||
identifier: string;
|
||||
type: number;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
isTrusted: boolean;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -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 |
@@ -18,16 +18,18 @@ import {
|
||||
revokeCurrentSession,
|
||||
getTotpStatus,
|
||||
saveSession,
|
||||
stripProfileSecrets,
|
||||
} from '@/lib/api/auth';
|
||||
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
||||
import { buildSendShareKey, getSends } from '@/lib/api/send';
|
||||
import {
|
||||
getCiphers,
|
||||
getFolders,
|
||||
repairCipherAttachmentMetadata,
|
||||
updateFolder,
|
||||
} from '@/lib/api/vault';
|
||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
|
||||
import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto';
|
||||
import {
|
||||
buildPublicSendUrl,
|
||||
deriveSendKeyParts,
|
||||
@@ -82,48 +84,12 @@ const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||
|
||||
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() {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {};
|
||||
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {};
|
||||
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);
|
||||
};
|
||||
}
|
||||
const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
|
||||
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
|
||||
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
|
||||
|
||||
function readThemePreference(): ThemePreference {
|
||||
if (typeof window === 'undefined') return 'system';
|
||||
@@ -137,6 +103,18 @@ function resolveSystemTheme(): 'light' | 'dark' {
|
||||
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() {
|
||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||
@@ -170,6 +148,7 @@ export default function App() {
|
||||
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||
@@ -180,10 +159,13 @@ export default function App() {
|
||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||
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 [mobileLayout, setMobileLayout] = useState(false);
|
||||
const [mobileSidebarToggleKey, setMobileSidebarToggleKey] = useState(0);
|
||||
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
||||
@@ -245,7 +227,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
sync();
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
@@ -287,12 +269,20 @@ export default function App() {
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'locked' && profile?.key && session) {
|
||||
if (phase === 'locked' && session?.email) {
|
||||
setUnlockPreparing(false);
|
||||
}
|
||||
}, [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() {
|
||||
setThemePreference((prev) => {
|
||||
@@ -307,6 +297,16 @@ export default function App() {
|
||||
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(
|
||||
() =>
|
||||
createAuthedFetch(
|
||||
@@ -353,7 +353,7 @@ export default function App() {
|
||||
setSession(boot.session);
|
||||
setProfile(boot.profile);
|
||||
setPhase(boot.phase);
|
||||
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
|
||||
setUnlockPreparing(boot.phase === 'locked' && !boot.session?.email);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -377,7 +377,7 @@ export default function App() {
|
||||
}
|
||||
setSession(result.session);
|
||||
if (result.profile) {
|
||||
setProfile(result.profile);
|
||||
setProfile(stripProfileSecrets(result.profile));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -385,17 +385,19 @@ export default function App() {
|
||||
};
|
||||
}, [phase, session?.email, location, navigate]);
|
||||
|
||||
async function finalizeLogin(login: CompletedLogin) {
|
||||
async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) {
|
||||
setSession(login.session);
|
||||
setProfile(login.profile);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
setTotpCode('');
|
||||
setUnlockPassword('');
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||
navigate('/vault');
|
||||
}
|
||||
pushToast('success', t('txt_login_success'));
|
||||
pushToast('success', successMessage);
|
||||
void (async () => {
|
||||
try {
|
||||
const hydratedProfile = await login.profilePromise;
|
||||
@@ -422,6 +424,7 @@ export default function App() {
|
||||
}
|
||||
if (result.kind === 'totp') {
|
||||
setPendingTotp(result.pendingTotp);
|
||||
setPendingTotpMode('login');
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
return;
|
||||
@@ -444,7 +447,7 @@ export default function App() {
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
await finalizeLogin(login);
|
||||
await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success'));
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||
} finally {
|
||||
@@ -567,20 +570,26 @@ export default function App() {
|
||||
|
||||
async function handleUnlock() {
|
||||
if (pendingAuthAction) return;
|
||||
if (!session || !profile) return;
|
||||
if (!session?.email) return;
|
||||
if (!unlockPassword) {
|
||||
pushToast('error', t('txt_please_input_master_password'));
|
||||
return;
|
||||
}
|
||||
setPendingAuthAction('unlock');
|
||||
try {
|
||||
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||
setSession(nextSession);
|
||||
setUnlockPassword('');
|
||||
setUnlockPreparing(false);
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/lock') navigate('/vault');
|
||||
pushToast('success', t('txt_unlocked'));
|
||||
const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||
if (result.kind === 'success') {
|
||||
await finalizeLogin(result.login, t('txt_unlocked'));
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'totp') {
|
||||
setPendingTotp(result.pendingTotp);
|
||||
setPendingTotpMode('unlock');
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
return;
|
||||
}
|
||||
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
|
||||
} catch {
|
||||
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
|
||||
} finally {
|
||||
@@ -588,17 +597,30 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleLock() {
|
||||
if (!session) return;
|
||||
const nextSession = { ...session };
|
||||
function lockCurrentSession() {
|
||||
const currentSession = sessionRef.current;
|
||||
if (!currentSession) return;
|
||||
const nextSession = { ...currentSession };
|
||||
delete nextSession.symEncKey;
|
||||
delete nextSession.symMacKey;
|
||||
setSession(nextSession);
|
||||
setProfile((prev) => stripProfileSecrets(prev));
|
||||
setDecryptedFolders([]);
|
||||
setDecryptedCiphers([]);
|
||||
setDecryptedSends([]);
|
||||
setUnlockPassword('');
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
setTotpCode('');
|
||||
setUnlockPreparing(false);
|
||||
setPhase('locked');
|
||||
navigate('/lock');
|
||||
}
|
||||
|
||||
function handleLock() {
|
||||
lockCurrentSession();
|
||||
}
|
||||
|
||||
function logoutNow() {
|
||||
void revokeCurrentSession(sessionRef.current);
|
||||
setConfirm(null);
|
||||
@@ -607,6 +629,7 @@ export default function App() {
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
setPhase('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() {
|
||||
return (
|
||||
<AppGlobalOverlays
|
||||
@@ -725,6 +804,34 @@ export default function App() {
|
||||
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(
|
||||
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) {
|
||||
nextCipher.card = {
|
||||
...cipher.card,
|
||||
@@ -822,10 +937,45 @@ export default function App() {
|
||||
}
|
||||
if (Array.isArray(cipher.attachments)) {
|
||||
nextCipher.attachments = await Promise.all(
|
||||
cipher.attachments.map(async (attachment) => ({
|
||||
...attachment,
|
||||
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac),
|
||||
}))
|
||||
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,
|
||||
decFileName: fileNameResult.text,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return nextCipher;
|
||||
@@ -1139,6 +1289,7 @@ export default function App() {
|
||||
profile,
|
||||
session,
|
||||
mobileLayout,
|
||||
mobileSidebarToggleKey,
|
||||
importRoute: IMPORT_ROUTE,
|
||||
settingsHomeRoute: SETTINGS_HOME_ROUTE,
|
||||
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
|
||||
@@ -1151,6 +1302,8 @@ export default function App() {
|
||||
users: usersQuery.data || [],
|
||||
invites: invitesQuery.data || [],
|
||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||
lockTimeoutMinutes,
|
||||
sessionTimeoutAction,
|
||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||
onNavigate: navigate,
|
||||
@@ -1195,7 +1348,12 @@ export default function App() {
|
||||
},
|
||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
onGetApiKey: accountSecurityActions.getApiKey,
|
||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
||||
@@ -1256,7 +1414,7 @@ export default function App() {
|
||||
<AuthViews
|
||||
mode={phase}
|
||||
pendingAction={pendingAuthAction}
|
||||
unlockReady={!!profile?.key && !!session}
|
||||
unlockReady={!!session?.email}
|
||||
unlockPreparing={unlockPreparing}
|
||||
loginValues={loginValues}
|
||||
registerValues={registerValues}
|
||||
@@ -1298,12 +1456,14 @@ export default function App() {
|
||||
onCancelTotp={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
}}
|
||||
onUseRecoveryCode={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
navigate('/recover-2fa');
|
||||
@@ -1337,6 +1497,7 @@ export default function App() {
|
||||
onLock={handleLock}
|
||||
onLogout={handleLogout}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
|
||||
mainRoutesProps={mainRoutesProps}
|
||||
/>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AppAuthenticatedShellProps {
|
||||
onLock: () => void;
|
||||
onLogout: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onToggleMobileSidebar: () => void;
|
||||
mainRoutesProps: AppMainRoutesProps;
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<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>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
@@ -51,7 +52,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
className="btn btn-secondary small mobile-sidebar-toggle"
|
||||
aria-label={props.sidebarToggleTitle}
|
||||
title={props.sidebarToggleTitle}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
||||
onClick={props.onToggleMobileSidebar}
|
||||
>
|
||||
<FolderIcon size={16} className="btn-icon" />
|
||||
</button>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
<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)} />
|
||||
</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)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
</label>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface AppMainRoutesProps {
|
||||
profile: Profile | null;
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
mobileSidebarToggleKey: number;
|
||||
importRoute: string;
|
||||
settingsHomeRoute: string;
|
||||
settingsAccountRoute: string;
|
||||
@@ -45,6 +46,8 @@ export interface AppMainRoutesProps {
|
||||
users: AdminUser[];
|
||||
invites: AdminInvite[];
|
||||
totpEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
authorizedDevicesLoading: boolean;
|
||||
onNavigate: (path: string) => void;
|
||||
@@ -94,7 +97,12 @@ export interface AppMainRoutesProps {
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
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>;
|
||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRevokeAllDeviceTrust: () => void;
|
||||
@@ -162,6 +170,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onBulkDelete={props.onBulkDeleteSends}
|
||||
uploadingSendFileName={props.uploadingSendFileName}
|
||||
sendUploadPercent={props.sendUploadPercent}
|
||||
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -201,6 +210,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||
/>
|
||||
</Suspense>
|
||||
</Route>
|
||||
@@ -219,11 +229,17 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
onChangePassword={props.onChangePassword}
|
||||
onSavePasswordHint={props.onSavePasswordHint}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -281,6 +297,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
devices={props.authorizedDevices}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
onRemoveDevice={props.onRemoveDevice}
|
||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { TriangleAlert } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -42,6 +42,24 @@ function decrementDialogBodyLock() {
|
||||
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) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -64,7 +82,12 @@ export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | nu
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const [present, setPresent] = useState(props.open);
|
||||
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(() => {
|
||||
if (props.open) {
|
||||
@@ -83,6 +106,72 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
|
||||
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;
|
||||
return createPortal((
|
||||
<div
|
||||
@@ -93,10 +182,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
}}
|
||||
>
|
||||
<form
|
||||
ref={cardRef}
|
||||
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.title}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={messageId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.confirmDisabled || closing) return;
|
||||
@@ -114,13 +207,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<h3 className="dialog-title">{props.title}</h3>
|
||||
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
||||
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{props.children}
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
@@ -129,6 +223,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
disabled={props.cancelDisabled}
|
||||
data-dialog-cancel="true"
|
||||
onClick={() => {
|
||||
if (props.cancelDisabled) return;
|
||||
props.onCancel();
|
||||
|
||||
@@ -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 { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||
import { toBufferSource } from '@/lib/crypto';
|
||||
@@ -11,29 +11,95 @@ interface PublicSendPageProps {
|
||||
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) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [password, setPassword] = useState('');
|
||||
const [needPassword, setNeedPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sendData, setSendData] = useState<any>(null);
|
||||
const [sendData, setSendData] = useState<PublicSendData | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
||||
const loadRequestRef = useRef(0);
|
||||
const loadAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
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);
|
||||
setError('');
|
||||
setLoading(true);
|
||||
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) {
|
||||
setError(t('txt_this_link_is_missing_decryption_key'));
|
||||
setSendData(null);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
setNeedPassword(true);
|
||||
@@ -43,6 +109,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
}
|
||||
setSendData(null);
|
||||
} finally {
|
||||
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||
setBusy(false);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -86,6 +153,9 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
void loadSend();
|
||||
return () => {
|
||||
loadAbortRef.current?.abort();
|
||||
};
|
||||
}, [props.accessId, props.keyPart]);
|
||||
|
||||
return (
|
||||
@@ -120,13 +190,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
|
||||
{!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 ? (
|
||||
<div className="card" style={{ marginTop: '10px' }}>
|
||||
<div className="card public-send-card">
|
||||
<div className="notes">{sendData.decText || ''}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ marginTop: '10px' }}>
|
||||
<div className="card public-send-card">
|
||||
<div className="kv-line">
|
||||
<span>{t('txt_file')}</span>
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
{!!error && <p className="local-error">{error}</p>}
|
||||
|
||||
@@ -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 { t } from '@/lib/i18n';
|
||||
|
||||
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
|
||||
devices: AuthorizedDevice[];
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRevokeAll: () => void;
|
||||
@@ -41,13 +44,30 @@ function mapDeviceTypeName(type: number): string {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<>
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
||||
<div className="muted-inline" style={{ marginTop: 4 }}>
|
||||
<h3 className="flush-title">{t('txt_device_management')}</h3>
|
||||
<div className="muted-inline section-note">
|
||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,10 +86,10 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
|
||||
<section className="card">
|
||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<tr key={device.identifier}>
|
||||
<td data-label={t('txt_device')}>
|
||||
<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>
|
||||
</td>
|
||||
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
</span>
|
||||
</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')}>
|
||||
{device.trusted ? (
|
||||
<div className="trusted-cell">
|
||||
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
onClick={() => props.onRevokeTrust(device)}
|
||||
>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_revoke_trust')}
|
||||
{t('txt_untrust')}
|
||||
</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" />
|
||||
{t('txt_remove_device_2')}
|
||||
{t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -129,13 +169,41 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
{!props.loading && props.devices.length === 0 && (
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ interface SendsPageProps {
|
||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||
uploadingSendFileName: string;
|
||||
sendUploadPercent: number | null;
|
||||
mobileSidebarToggleKey: number;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||
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 {
|
||||
if (!iso) return String(fallback);
|
||||
@@ -107,12 +108,9 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onToggleSidebar = () => {
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
};
|
||||
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
}, []);
|
||||
if (!props.mobileSidebarToggleKey) return;
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
}, [props.mobileSidebarToggleKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -325,8 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
{filteredSends.map((send, index) => (
|
||||
<div
|
||||
key={send.id}
|
||||
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
|
||||
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||
className={`list-item stagger-item stagger-delay-${Math.min(index, 10)} ${selectedId === send.id ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check')) return;
|
||||
@@ -405,7 +402,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
)}
|
||||
{isEditing && draft && (
|
||||
<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>
|
||||
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||
<div className="field-grid">
|
||||
@@ -505,12 +502,12 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
|
||||
{!isEditing && selectedSend && (
|
||||
<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>
|
||||
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
||||
</div>
|
||||
|
||||
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
|
||||
<div className="card stagger-item stagger-delay-2">
|
||||
<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_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||
@@ -533,7 +530,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
|
||||
{!!(selectedSend.decNotes || '').trim() && (
|
||||
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
|
||||
<div className="card stagger-item stagger-delay-3">
|
||||
<h4>{t('txt_notes')}</h4>
|
||||
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 qrcode from 'qrcode-generator';
|
||||
import type { Profile } from '@/lib/types';
|
||||
@@ -9,14 +9,28 @@ import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
totpEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
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`;
|
||||
}
|
||||
|
||||
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) {
|
||||
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPassword2, setNewPassword2] = useState('');
|
||||
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 [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = 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(() => {
|
||||
if (!props.totpEnabled) {
|
||||
@@ -73,20 +108,58 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
async function enableTotp(): Promise<void> {
|
||||
try {
|
||||
await props.onEnableTotp(secret, token);
|
||||
// Secret is now stored on the server; remove plaintext copy from localStorage.
|
||||
localStorage.removeItem(totpSecretStorageKey);
|
||||
setTotpLocked(true);
|
||||
} catch {
|
||||
// Keep inputs editable after a failed attempt.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecoveryCode(): Promise<void> {
|
||||
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
|
||||
setRecoveryCode(code);
|
||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
|
||||
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);
|
||||
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 {
|
||||
if (!value) return t('txt_dash');
|
||||
const parsed = new Date(value);
|
||||
@@ -95,30 +168,44 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>{t('txt_profile')}</h3>
|
||||
<label className="field">
|
||||
<span>{t('txt_password_hint_optional')}</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>
|
||||
<div className="settings-modules-grid">
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_session_timeout')}</h3>
|
||||
<div className="session-timeout-fields">
|
||||
<label className="field">
|
||||
<span>{t('txt_timeout_time')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={String(props.lockTimeoutMinutes)}
|
||||
onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
|
||||
>
|
||||
{LOCK_TIMEOUT_OPTIONS.map((option) => (
|
||||
<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 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>
|
||||
<label className="field">
|
||||
<span>{t('txt_current_password')}</span>
|
||||
@@ -149,71 +236,87 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="settings-twofactor-grid">
|
||||
<div className="settings-subcard">
|
||||
<h3>{t('txt_totp')}</h3>
|
||||
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr">
|
||||
<img src={qrDataUrl} alt="TOTP QR" />
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>{t('txt_authenticator_key')}</span>
|
||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_regenerate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={totpLocked}
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy_secret')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_password_hint_optional')}</h3>
|
||||
<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>
|
||||
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr">
|
||||
<img src={qrDataUrl} alt="TOTP QR" />
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>{t('txt_authenticator_key')}</span>
|
||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_regenerate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={totpLocked}
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy_secret')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_disable_totp')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_disable_totp')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="settings-subcard">
|
||||
<h3>{t('txt_recovery_code')}</h3>
|
||||
<p className="muted-inline" style={{ marginBottom: 8 }}>
|
||||
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
||||
</p>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={recoveryMasterPassword}
|
||||
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_recovery_code_and_api_key')}</h3>
|
||||
<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')}
|
||||
</p>
|
||||
</div>
|
||||
<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" />
|
||||
{t('txt_view_recovery_code')}
|
||||
</button>
|
||||
@@ -230,13 +333,109 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
{recoveryCode && (
|
||||
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
|
||||
<div className="recovery-code-card">
|
||||
<div className="recovery-code-value">{recoveryCode}</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||
<div className="standalone-brand standalone-brand-outside">
|
||||
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
|
||||
<div>
|
||||
<div className="standalone-brand-title">NodeWarden</div>
|
||||
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="standalone-brand-wordmark" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@ const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
||||
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 {
|
||||
if (!code) return code;
|
||||
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 host = hostFromUri(uri);
|
||||
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) {
|
||||
return (
|
||||
<img
|
||||
className="list-icon"
|
||||
src={websiteIconUrl(host)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => {
|
||||
failedIconHosts.add(host);
|
||||
setErrored(true);
|
||||
}}
|
||||
/>
|
||||
<span className="list-icon-stack">
|
||||
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
|
||||
<Globe size={18} />
|
||||
</span>
|
||||
<img
|
||||
className={`list-icon ${loaded ? 'loaded' : ''}`}
|
||||
src={websiteIconUrl(host)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
ref={syncCachedIconState}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={markIconError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -164,7 +194,8 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
||||
}
|
||||
|
||||
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 [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
@@ -247,26 +278,39 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!totpItems.length) {
|
||||
setTotpMap({});
|
||||
setTotpCodes({});
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
let activeRun = 0;
|
||||
let timer = 0;
|
||||
const tick = async () => {
|
||||
let currentWindowId = -1;
|
||||
|
||||
const refreshCodes = async () => {
|
||||
const runId = ++activeRun;
|
||||
const entries = await Promise.all(
|
||||
totpItems.map(async (cipher) => {
|
||||
try {
|
||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||
return [cipher.id, next] as const;
|
||||
return [cipher.id, next?.code || null] as const;
|
||||
} catch {
|
||||
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 () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
@@ -322,7 +366,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
<SortableTotpRow
|
||||
key={cipher.id}
|
||||
cipher={cipher}
|
||||
live={totpMap[cipher.id] || null}
|
||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
||||
onCopy={(value) => void copyToClipboard(value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -58,6 +58,7 @@ interface VaultPageProps {
|
||||
attachmentDownloadPercent: number | null;
|
||||
uploadingAttachmentName: string;
|
||||
attachmentUploadPercent: number | null;
|
||||
mobileSidebarToggleKey: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,12 +132,9 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onToggleSidebar = () => {
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
};
|
||||
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
}, []);
|
||||
if (!props.mobileSidebarToggleKey) return;
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
}, [props.mobileSidebarToggleKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const onQuickAdd = () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
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 { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -35,10 +37,60 @@ interface VaultDetailViewProps {
|
||||
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) {
|
||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
||||
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 downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -53,7 +105,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<div className="card">
|
||||
<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="actions" style={{ marginTop: '10px' }}>
|
||||
<div className="actions detail-unlock-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
|
||||
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
|
||||
</button>
|
||||
@@ -65,7 +117,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||
<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>
|
||||
|
||||
{props.selectedCipher.login && (
|
||||
@@ -355,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<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_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>
|
||||
)}
|
||||
|
||||
@@ -379,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<PasswordHistoryDialog
|
||||
open={passwordHistoryOpen}
|
||||
entries={passwordHistoryEntries}
|
||||
onClose={() => setPasswordHistoryOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
</DndContext>
|
||||
{props.draft.loginFido2Credentials.length > 0 && (
|
||||
<>
|
||||
<div className="section-head" style={{ marginTop: '18px' }}>
|
||||
<div className="section-head passkeys-section-head">
|
||||
<h4>{t('txt_passkeys')}</h4>
|
||||
</div>
|
||||
<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)}>
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher, index) => (
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
<div
|
||||
key={cipher.id}
|
||||
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||
className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check')) return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import {
|
||||
CreditCard,
|
||||
FileKey2,
|
||||
@@ -36,8 +36,8 @@ export const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
||||
];
|
||||
|
||||
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
export const VAULT_LIST_ROW_HEIGHT = 66;
|
||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||
export const VAULT_LIST_OVERSCAN = 10;
|
||||
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
@@ -161,7 +161,7 @@ export function hostFromUri(uri: 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 {
|
||||
@@ -433,19 +433,41 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||
const uri = firstCipherUri(cipher);
|
||||
const host = hostFromUri(uri);
|
||||
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) {
|
||||
return (
|
||||
<img
|
||||
className="list-icon"
|
||||
src={websiteIconUrl(host)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => {
|
||||
failedIconHosts.add(host);
|
||||
setErrored(true);
|
||||
}}
|
||||
/>
|
||||
<span className="list-icon-stack">
|
||||
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
|
||||
<Globe size={18} />
|
||||
</span>
|
||||
<img
|
||||
className={`list-icon ${loaded ? 'loaded' : ''}`}
|
||||
src={websiteIconUrl(host)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
ref={syncCachedIconState}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={markIconError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
deleteAuthorizedDevice,
|
||||
deriveLoginHash,
|
||||
getCurrentDeviceIdentifier,
|
||||
getApiKey,
|
||||
getTotpRecoveryCode,
|
||||
rotateApiKey,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
setTotp,
|
||||
updateAuthorizedDeviceName,
|
||||
updateProfile,
|
||||
} from '@/lib/api/auth';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -128,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
try {
|
||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
|
||||
clearDisableTotpDialog();
|
||||
await refetchTotpStatus();
|
||||
onNotify('success', t('txt_totp_disabled'));
|
||||
@@ -147,10 +149,45 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
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() {
|
||||
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) {
|
||||
onSetConfirm({
|
||||
title: t('txt_revoke_device_authorization'),
|
||||
@@ -159,9 +196,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_authorization_revoked'));
|
||||
try {
|
||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_authorization_revoked'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
@@ -175,14 +216,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||
try {
|
||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||
onNotify('success', t('txt_device_removed'));
|
||||
onLogoutNow();
|
||||
return;
|
||||
}
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_removed'));
|
||||
onLogoutNow();
|
||||
return;
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
|
||||
}
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_removed'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
@@ -196,9 +241,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
||||
try {
|
||||
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
@@ -212,9 +261,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await deleteAllAuthorizedDevices(authedFetch);
|
||||
onNotify('success', t('txt_all_devices_removed'));
|
||||
onLogoutNow();
|
||||
try {
|
||||
await deleteAllAuthorizedDevices(authedFetch);
|
||||
onNotify('success', t('txt_all_devices_removed'));
|
||||
onLogoutNow();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -122,9 +122,11 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||
if (!raw) return null;
|
||||
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;
|
||||
return parsed;
|
||||
const snapshot = stripProfileSecrets(parsed);
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||||
return snapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -132,13 +134,27 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
|
||||
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||
if (!profile) return;
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile)));
|
||||
}
|
||||
|
||||
export function clearProfileSnapshot(): void {
|
||||
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 {
|
||||
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'));
|
||||
}
|
||||
|
||||
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> {
|
||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||
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 || '');
|
||||
}
|
||||
|
||||
@@ -260,18 +260,24 @@ async function buildPublicSendAccessPayload(password?: string, keyPart?: string
|
||||
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 resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const message = await parseErrorMessage(resp, 'Failed to access send');
|
||||
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> {
|
||||
@@ -290,19 +296,22 @@ export async function accessPublicSendFile(sendId: string, fileId: string, keyPa
|
||||
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 sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||
const out: any = { ...accessData };
|
||||
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
|
||||
if (accessData?.text?.text) {
|
||||
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac);
|
||||
const source = accessData && typeof accessData === 'object' ? accessData as Record<string, unknown> : {};
|
||||
const text = source.text && typeof source.text === 'object' ? source.text as Record<string, unknown> : null;
|
||||
const file = source.file && typeof source.file === 'object' ? source.file as Record<string, unknown> : null;
|
||||
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 {
|
||||
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac);
|
||||
out.decFileName = await decryptStr(String(file.fileName), sendKey.enc, sendKey.mac);
|
||||
} catch {
|
||||
out.decFileName = String(accessData.file.fileName);
|
||||
out.decFileName = String(file.fileName);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -14,7 +14,13 @@ export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<V
|
||||
if (existing) return existing;
|
||||
|
||||
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');
|
||||
const body = await parseJson<VaultSyncResponse>(resp);
|
||||
return body || {};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
|
||||
import type {
|
||||
Cipher,
|
||||
CipherPasswordHistoryEntry,
|
||||
Folder,
|
||||
SessionState,
|
||||
VaultDraft,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
parseErrorMessage,
|
||||
parseJson,
|
||||
uploadDirectEncryptedPayload,
|
||||
uploadWithProgress,
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
@@ -272,6 +274,98 @@ export async function deleteCipherAttachment(
|
||||
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(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
@@ -292,32 +386,76 @@ export async function downloadCipherAttachmentDecrypted(
|
||||
const userEnc = base64ToBytes(session.symEncKey);
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
|
||||
const userKeys = { enc: userEnc, mac: userMac };
|
||||
|
||||
let fileEnc = itemKeys.enc;
|
||||
let fileMac = itemKeys.mac;
|
||||
const candidates: AttachmentDecryptCandidate[] = [];
|
||||
const keyCipher = String(info.key || '').trim();
|
||||
if (keyCipher && looksLikeCipherString(keyCipher)) {
|
||||
try {
|
||||
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac);
|
||||
if (fileRawKey.length >= 64) {
|
||||
fileEnc = fileRawKey.slice(0, 32);
|
||||
fileMac = fileRawKey.slice(32, 64);
|
||||
const itemWrappedKey = await decryptCipherStringWithKey(keyCipher, itemKeys.enc, itemKeys.mac);
|
||||
if (itemWrappedKey && itemWrappedKey.length >= 64) {
|
||||
candidates.push({
|
||||
mode: 'attachment-item',
|
||||
enc: itemWrappedKey.slice(0, 32),
|
||||
mac: itemWrappedKey.slice(32, 64),
|
||||
rawAttachmentKey: itemWrappedKey,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// fallback to item key
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
|
||||
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();
|
||||
let fileName = fileNameRaw || `attachment-${aid}`;
|
||||
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
|
||||
try {
|
||||
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName;
|
||||
} catch {
|
||||
// keep fallback name
|
||||
const nameResult = await decryptAttachmentFileName(fileNameRaw, itemKeys, userKeys);
|
||||
const fileName = nameResult.fileName || `attachment-${aid}`;
|
||||
|
||||
try {
|
||||
const metadata: { fileName?: string; key?: string | null } = {};
|
||||
if (nameResult.source === 'user') {
|
||||
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 };
|
||||
@@ -346,6 +484,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
|
||||
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(
|
||||
fields: VaultDraftField[],
|
||||
enc: Uint8Array,
|
||||
@@ -473,6 +666,7 @@ async function buildCipherPayload(
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const keys = await getCipherKeys(cipher, userEnc, userMac);
|
||||
const type = Number(draft.type || cipher?.type || 1);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
type,
|
||||
@@ -487,6 +681,7 @@ async function buildCipherPayload(
|
||||
secureNote: null,
|
||||
sshKey: null,
|
||||
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
||||
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
|
||||
};
|
||||
|
||||
if (cipher?.id) {
|
||||
@@ -495,6 +690,7 @@ async function buildCipherPayload(
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || '');
|
||||
const existingFido2 =
|
||||
cipher?.login && Array.isArray((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),
|
||||
password: await encryptTextValue(draft.loginPassword, 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),
|
||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||
};
|
||||
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
|
||||
} else if (type === 3) {
|
||||
payload.card = {
|
||||
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
||||
|
||||
@@ -372,16 +372,36 @@ export async function performRegistration(args: {
|
||||
|
||||
export async function performUnlock(
|
||||
session: SessionState,
|
||||
profile: Profile,
|
||||
profile: Profile | null,
|
||||
password: string,
|
||||
fallbackIterations: number
|
||||
): Promise<SessionState> {
|
||||
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations);
|
||||
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession) {
|
||||
throw new Error('Session expired');
|
||||
): Promise<PasswordLoginResult> {
|
||||
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_bulk_delete_sends_failed: "Bulk delete sends failed",
|
||||
txt_bulk_move_failed: "Bulk move failed",
|
||||
txt_cancel: "Cancel",
|
||||
txt_continue: "Continue",
|
||||
txt_card: "Card",
|
||||
txt_card_details: "Card Details",
|
||||
txt_cardholder_name: "Cardholder Name",
|
||||
@@ -387,6 +388,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_device: "Device",
|
||||
txt_device_authorization_revoked: "Device trust revoked",
|
||||
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_load_devices_failed: "Failed to load devices",
|
||||
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_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_continue: "Enter your master password to continue.",
|
||||
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
|
||||
txt_expiration_date: "Expiration Date",
|
||||
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_deleted: "Item deleted",
|
||||
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_updated: "Item updated",
|
||||
txt_last_edited_value: "Last edited: {value}",
|
||||
@@ -550,6 +557,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_not_trusted: "Not trusted",
|
||||
txt_note: "Note",
|
||||
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_open: "Open",
|
||||
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_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.",
|
||||
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_is_empty: "Recovery code is empty",
|
||||
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_in_seconds_s: "Refresh in {seconds}s",
|
||||
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_all_device_trust_failed: "Failed to revoke all device trust",
|
||||
txt_revoke_trust: "Revoke Trust",
|
||||
txt_untrust: "Untrust",
|
||||
txt_update_device_note_failed: "Update device note failed",
|
||||
txt_role: "Role",
|
||||
txt_save: "Save",
|
||||
txt_save_profile: "Save Profile",
|
||||
@@ -1018,6 +1044,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_confirm_master_password: '确认主密码',
|
||||
txt_submit: '提交',
|
||||
txt_cancel: '取消',
|
||||
txt_continue: '继续',
|
||||
txt_yes: '是',
|
||||
txt_no: '否',
|
||||
txt_loading: '加载中...',
|
||||
@@ -1067,7 +1094,10 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_additional_options: '附加选项',
|
||||
txt_custom_fields: '自定义字段',
|
||||
txt_notes: '备注',
|
||||
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
|
||||
txt_item_history: '项目历史',
|
||||
txt_password_history: '密码历史记录',
|
||||
txt_password_updated_value: '密码新于: {value}',
|
||||
txt_last_edited_value: '最后编辑:{value}',
|
||||
txt_created_value: '创建于:{value}',
|
||||
txt_username: '用户名',
|
||||
@@ -1113,12 +1143,17 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_view_recovery_code: '查看恢复代码',
|
||||
txt_copy_code: '复制代码',
|
||||
txt_device_management: '设备管理',
|
||||
txt_device_note: '备注',
|
||||
txt_device_note_required: '设备名称不能为空',
|
||||
txt_device_note_updated: '设备名称已更新',
|
||||
txt_authorized_devices: '已授权设备',
|
||||
txt_device: '设备',
|
||||
txt_last_seen: '最后在线',
|
||||
txt_trusted_until: '信任至',
|
||||
txt_revoke_trust: '撤销信任',
|
||||
txt_untrust: '不信任',
|
||||
txt_remove_device_2: '移除设备',
|
||||
txt_update_device_note_failed: '更新设备备注失败',
|
||||
txt_not_trusted: '未信任',
|
||||
txt_unknown_device: '未知设备',
|
||||
txt_users: '用户',
|
||||
@@ -1277,6 +1312,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_encrypted_file_2: '加密文件',
|
||||
txt_enter_a_folder_name: '请输入文件夹名称',
|
||||
txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证',
|
||||
txt_enter_master_password_to_continue: '输入主密码以继续',
|
||||
txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目',
|
||||
txt_expiry: '有效期',
|
||||
txt_expiry_month: '有效期月',
|
||||
@@ -1345,8 +1381,24 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_recover_2fa_failed: '恢复 2FA 失败',
|
||||
txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录',
|
||||
txt_recovery_code_copied: '恢复代码已复制',
|
||||
txt_recovery_code_and_api_key: '恢复代码和 API 密钥',
|
||||
txt_recovery_code_is_empty: '恢复代码为空',
|
||||
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_registration_succeeded_please_sign_in: '注册成功,请登录',
|
||||
txt_remove_device: '移除设备',
|
||||
@@ -1439,6 +1491,48 @@ zhCNOverrides.txt_lock = '锁定';
|
||||
zhCNOverrides.txt_menu = '菜单';
|
||||
zhCNOverrides.txt_settings = '设置';
|
||||
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_upload_attachments = '上传附件';
|
||||
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||
|
||||
@@ -148,6 +148,12 @@ export interface CipherField {
|
||||
decValue?: string;
|
||||
}
|
||||
|
||||
export interface CipherPasswordHistoryEntry {
|
||||
password?: string | null;
|
||||
lastUsedDate?: string | null;
|
||||
decPassword?: string;
|
||||
}
|
||||
|
||||
export interface Cipher {
|
||||
id: string;
|
||||
type: number;
|
||||
@@ -167,7 +173,7 @@ export interface Cipher {
|
||||
identity?: CipherIdentity | null;
|
||||
sshKey?: CipherSshKey | null;
|
||||
secureNote?: { type?: number | null } | null;
|
||||
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
|
||||
passwordHistory?: CipherPasswordHistoryEntry[] | null;
|
||||
fields?: CipherField[] | null;
|
||||
decName?: string;
|
||||
decNotes?: string;
|
||||
@@ -338,10 +344,14 @@ export interface AdminInvite {
|
||||
export interface AuthorizedDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
systemName?: string | null;
|
||||
deviceNote?: string | null;
|
||||
identifier: string;
|
||||
type: number;
|
||||
creationDate: string | null;
|
||||
revisionDate: string | null;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
online: boolean;
|
||||
trusted: boolean;
|
||||
trustedTokenCount: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render } from 'preact';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './tailwind.css';
|
||||
import './styles.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||