Compare commits

..
13 Commits
Author SHA1 Message Date
shuaiplus b093c01fd7 chore: update version to 1.7.4 in package.json, package-lock.json, and app-version.ts 2026-07-12 22:13:33 +08:00
shuaiplus fa611dc843 fix: require master password for admin and wipe-device actions
Gate invite management, user ban/delete, and delete-all-devices behind
masterPasswordHash verification, matching backup step-up auth. The web UI
prompts for the master password in the shared confirm dialog.
2026-07-12 20:43:27 +08:00
shuaiplus 3c581d1fb1 fix: block IPv6 loopback in backup destination URL checks
Expand compressed IPv6 hostnames before the private-address allowlist so
forms like ::1 cannot bypass SSRF protection for WebDAV/S3 backup endpoints.
Also reject IPv4-mapped addresses written as ::ffff:hex:hex.
2026-07-12 20:21:45 +08:00
shuaiplus fb376797d2 feat: update PasswordGeneratorPage styles and improve layout responsiveness 2026-07-12 01:59:15 +08:00
shuaiplus 99b50275a6 feat: add Password Security feature with scanning and reporting capabilities 2026-07-12 01:50:21 +08:00
shuaiplus dfc98008cb Add password generator feature and update localization files 2026-07-11 18:49:26 +08:00
shuaiplus b472121f43 feat: add contributors section and star history chart to README files 2026-07-11 17:25:53 +08:00
DomainmasteriandGitHub 9caa064488 Add German (de), French (fr), Italian (it), and Swedish (sv) initial translations (#303) 2026-07-11 17:06:41 +08:00
shuaiplus aae614a079 feat: add offline mode notice and related styles to enhance user experience during offline access 2026-07-10 22:24:06 +08:00
shuaiplus 0e46cd371f Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-10 14:24:15 +08:00
shuaiplus db31792cef feat: add fill-assist, assetlinks check, and web-bootstrap to worker-handled paths 2026-07-10 14:23:45 +08:00
shuaiplus 8c65cb2e80 feat: update FIDO2 origins and enable direct unlock for account passkeys 2026-07-10 14:22:24 +08:00
shuaiplus 14dff8ee6a feat: disable new-device verification and update related logic across services 2026-07-10 13:00:32 +08:00
54 changed files with 15309 additions and 266 deletions
+13 -1
View File
@@ -133,6 +133,18 @@ LGPL-3.0 License
--- ---
## Contributors
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left) <a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
+13 -1
View File
@@ -132,6 +132,18 @@ LGPL-3.0 License
--- ---
## 贡献者
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left) <a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
+1 -1
View File
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL, security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1, verify_devices INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT, totp_secret TEXT,
totp_recovery_code TEXT, totp_recovery_code TEXT,
api_key TEXT, api_key TEXT,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
+4
View File
@@ -14,6 +14,10 @@ const localeFiles = [
['ru', 'ru.ts', 'ru', 'Russian'], ['ru', 'ru.ts', 'ru', 'Russian'],
['es', 'es.ts', 'es', 'Spanish'], ['es', 'es.ts', 'es', 'Spanish'],
['fi', 'fi.ts', 'fi', 'Finnish'], ['fi', 'fi.ts', 'fi', 'Finnish'],
['de', 'de.ts', 'de', 'German'],
['fr', 'fr.ts', 'fr', 'French'],
['it', 'it.ts', 'it', 'Italian'],
['sv', 'sv.ts', 'sv', 'Swedish'],
]; ];
function readLocale(fileName, variableName) { function readLocale(fileName, variableName) {
@@ -0,0 +1,38 @@
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
import fs from 'node:fs';
const scratch = process.env.SCRATCH || '.';
const cases = [
'http://127.0.0.1',
'http://169.254.169.254',
'http://[::1]',
'http://[0:0:0:0:0:0:0:1]',
'http://[::2]',
'http://[::]',
'http://[fe80::1]',
'http://[fc00::1]',
'https://example.com',
];
const out = [];
for (const url of cases) {
try {
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
out.push({ url, allowed: true, normalized });
} catch (e) {
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
}
}
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
fs.writeFileSync(path, JSON.stringify(out, null, 2));
console.log(JSON.stringify(out, null, 2));
// Security expectation: IPv6 loopback must NOT be allowed.
const loopback = out.find((row) => row.url === 'http://[::1]');
if (loopback?.allowed) {
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
process.exitCode = 2;
} else {
console.log('IPv6 loopback rejected as expected');
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.3'; export const APP_VERSION = '1.7.4';
+31 -50
View File
@@ -352,7 +352,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(), securityStamp: generateUUID(),
role: 'user', role: 'user',
status: 'active', status: 'active',
verifyDevices: true, verifyDevices: false, // new-device verification requires email delivery (not available)
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
yubikeyKey1: null, yubikeyKey1: null,
@@ -553,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
} }
// PUT/POST /api/accounts/verify-devices // PUT/POST /api/accounts/verify-devices
// New-device verification requires an email delivery channel which NodeWarden
// does not provide. This endpoint always rejects the request so clients receive
// clear feedback that the feature is unavailable rather than silently ignoring
// the user's preference.
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env); const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let body: { // Log the attempt for audit purposes, but do not change state.
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
VerifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.verify_devices.update', action: 'account.verify_devices.update.rejected',
category: 'security', category: 'security',
level: 'security', level: 'info',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: { metadata: {
verifyDevices: user.verifyDevices, reason: 'new-device verification is not supported (no email delivery channel)',
...auditRequestMetadata(request), ...auditRequestMetadata(request),
}, },
}); });
return new Response(null, { status: 200 }); return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
} }
// GET /api/accounts/keys // GET /api/accounts/keys
@@ -819,13 +799,16 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
}; };
} }
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> { // New-device verification requires an email delivery channel to send OTP
const enabled = user.verifyDevices !== false; // challenges to unknown devices. NodeWarden does not integrate with an email
// provider, so this feature is intentionally unavailable. The settings
// response always reports disabled regardless of any legacy DB value.
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
return { return {
Enabled: enabled, Enabled: false,
enabled, enabled: false,
VerifyDevices: enabled, VerifyDevices: false,
verifyDevices: enabled, verifyDevices: false,
Object: 'deviceVerificationSettings', Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings', object: 'deviceVerificationSettings',
}; };
@@ -915,9 +898,10 @@ export async function handleGetDeviceVerificationSettings(request: Request, env:
} }
// PUT/POST /api/two-factor/device-verification-settings // PUT/POST /api/two-factor/device-verification-settings
// New-device verification is not supported (no email delivery channel).
// Reject any attempt to enable it; always return disabled state.
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> { export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
@@ -929,31 +913,28 @@ export async function handlePutDeviceVerificationSettings(request: Request, env:
} }
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices; const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
if (typeof rawEnabled !== 'boolean') {
return errorResponse('enabled must be true or false', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']); // Log the attempt for audit purposes — never change state.
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
user.verifyDevices = rawEnabled;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.verify_devices.update', action: 'account.verify_devices.update.rejected',
category: 'security', category: 'security',
level: 'security', level: 'info',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: { metadata: {
verifyDevices: user.verifyDevices, requested: rawEnabled,
reason: 'new-device verification is not supported (no email delivery channel)',
source: 'two-factor.device-verification-settings', source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request), ...auditRequestMetadata(request),
}, },
}); });
if (rawEnabled === true) {
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// Setting to false is the only supported state — return it.
return jsonResponse(deviceVerificationSettingsResponse(user)); return jsonResponse(deviceVerificationSettingsResponse(user));
} }
+47 -14
View File
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireMasterPasswordHash(
env: Env,
actorUser: User,
masterPasswordHash: unknown
): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const body = await request.json();
return body && typeof body === 'object' && !Array.isArray(body)
? body as Record<string, unknown>
: {};
} catch {
return {};
}
}
function randomHex(bytes: number): string { function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes)); const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join(''); return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
@@ -204,14 +232,11 @@ export async function handleAdminCreateInvite(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {}; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
body = {};
}
const expiresInHours = Number.isFinite(body.expiresInHours) const expiresInHours = Number.isFinite(Number(body.expiresInHours))
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours)))) ? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7; : 24 * 7;
const now = new Date(); const now = new Date();
@@ -266,6 +291,10 @@ export async function handleAdminDeleteInvite(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const deleted = await storage.deleteInvite(code); const deleted = await storage.deleteInvite(code);
if (!deleted) { if (!deleted) {
@@ -288,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const url = new URL(request.url); const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') { if (url.searchParams.get('scope') === 'invalid') {
@@ -318,12 +351,9 @@ export async function handleAdminSetUserStatus(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
let body: { status?: string }; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
return errorResponse('Invalid JSON', 400);
}
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null; const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) { if (!nextStatus) {
@@ -366,7 +396,6 @@ export async function handleAdminDeleteUser(
actorUser: User, actorUser: User,
targetUserId: string targetUserId: string
): Promise<Response> { ): Promise<Response> {
void request;
if (!isAdmin(actorUser)) { if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
@@ -374,6 +403,10 @@ export async function handleAdminDeleteUser(
return errorResponse('You cannot delete yourself', 400); return errorResponse('You cannot delete yourself', 400);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId); const target = await storage.getUserById(targetUserId);
if (!target) { if (!target) {
+16 -1
View File
@@ -464,11 +464,26 @@ export async function handleUpdateDeviceName(
// DELETE /api/devices // DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let masterPasswordHash = '';
try {
const body = await request.json() as { masterPasswordHash?: string };
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
} catch {
masterPasswordHash = '';
}
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([ const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId), storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId), storage.deleteRefreshTokensByUserId(userId),
+3
View File
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
path.startsWith('/api/') || path.startsWith('/api/') ||
path.startsWith('/identity/') || path.startsWith('/identity/') ||
path.startsWith('/icons/') || path.startsWith('/icons/') ||
path.startsWith('/fill-assist/') ||
path.startsWith('/notifications/') || path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') || path.startsWith('/.well-known/') ||
path === '/v1/assetlinks:check' ||
path === '/web-bootstrap' ||
path === '/config' || path === '/config' ||
path === '/api/config' || path === '/api/config' ||
path === '/api/version' path === '/api/version'
+54 -5
View File
@@ -99,23 +99,72 @@ function isBlockedIpv4Address(octets: number[]): boolean {
); );
} }
/**
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
* Needed so compressed forms like "::1" are not misclassified by a naive
* "first non-empty hextet" check (which would read "1" and miss loopback).
*/
function expandIpv6Address(hostname: string): string[] | null {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!normalized.includes(':')) return null;
if (normalized.includes('.')) {
// IPv4-embedded forms are handled separately by the caller.
return null;
}
if ((normalized.match(/::/g) || []).length > 1) return null;
const sides = normalized.split('::');
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
if (left.length + right.length > 8) return null;
if (sides.length === 1 && left.length !== 8) return null;
const missing = 8 - left.length - right.length;
if (sides.length > 1 && missing < 0) return null;
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
const parts = [...left, ...middle, ...right];
if (parts.length !== 8) return null;
const hextets: string[] = [];
for (const part of parts) {
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
hextets.push(part.padStart(4, '0'));
}
return hextets;
}
function isBlockedIpv6Address(hostname: string): boolean { function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false; if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase(); const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
// IPv4-mapped dotted form: ::ffff:127.0.0.1
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
if (mappedIpv4) { if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]); const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets); return !octets || isBlockedIpv4Address(octets);
} }
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
const firstHextet = Number.parseInt(firstHextetText, 16); // IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (mappedHex) {
const hi = Number.parseInt(mappedHex[1], 16);
const lo = Number.parseInt(mappedHex[2], 16);
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
return isBlockedIpv4Address(octets);
}
const hextets = expandIpv6Address(normalized);
if (!hextets) return true;
const firstHextet = Number.parseInt(hextets[0], 16);
if (!Number.isFinite(firstHextet)) return true; if (!Number.isFinite(firstHextet)) return true;
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
return ( return (
firstHextet === 0 || firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 || (firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 || (firstHextet & 0xff00) === 0xff00 ||
normalized.startsWith('2001:db8:') hextets.join(':').startsWith('2001:0db8:')
); );
} }
+1 -1
View File
@@ -301,7 +301,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []), config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({ users: cloneRows(payload.users || []).map((row) => ({
...row, ...row,
verify_devices: row.verify_devices ?? 1, verify_devices: row.verify_devices ?? 0,
yubikey_nfc: row.yubikey_nfc ?? 0, yubikey_nfc: row.yubikey_nfc ?? 0,
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
+2 -2
View File
@@ -14,11 +14,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key 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 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT', 'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
+1 -1
View File
@@ -23,7 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp, securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user', role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active', status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null, yubikeyKey1: row.yubikey_key1 ?? null,
+9 -1
View File
@@ -1,5 +1,13 @@
import type { Env } from '../types'; import type { Env } from '../types';
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
// These are the stable store IDs for the official Chromium-based extensions.
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const;
export function normalizeOrigin(value: unknown): string | null { export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return null; if (!raw) return null;
@@ -25,7 +33,7 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean {
export function getConfiguredWebAuthnAllowedOrigins( export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'> env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] { ): string[] {
const seen = new Set<string>(); const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) { for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item); const origin = normalizeOrigin(item);
if (origin) seen.add(origin); if (origin) seen.add(origin);
+3 -1
View File
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
forcePasswordReset: false, forcePasswordReset: false,
avatarColor: null, avatarColor: null,
creationDate: user.createdAt, creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false, // New-device verification is not supported without an email delivery channel.
// Always report disabled so clients do not present a false security posture.
verifyDevices: false,
role: user.role, role: user.role,
status: user.status, status: user.status,
object: 'profile', object: 'profile',
+1 -1
View File
@@ -9,7 +9,7 @@
script-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; img-src 'self' data:;
connect-src 'self'; connect-src 'self' https://api.pwnedpasswords.com;
font-src 'self'; font-src 'self';
form-action 'self'; form-action 'self';
base-uri 'self'; base-uri 'self';
+15
View File
@@ -68,6 +68,7 @@ import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { clearOfflineUnlockRecord } from '@/lib/offline-auth'; import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt'; import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker'; import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import { import {
@@ -111,6 +112,8 @@ const APP_ROUTE_PATHS = [
'/', '/',
'/vault', '/vault',
'/vault/totp', '/vault/totp',
'/security/password-health',
'/generator',
'/sends', '/sends',
'/admin', '/admin',
'/logs', '/logs',
@@ -385,6 +388,10 @@ export default function App() {
} }
}, [phase, profile, session]); }, [phase, profile, session]);
useEffect(() => {
if (phase !== 'app') clearPasswordSecurityCache();
}, [phase]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes)); window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
@@ -871,6 +878,7 @@ export default function App() {
setDecryptedFolders([]); setDecryptedFolders([]);
setDecryptedCiphers([]); setDecryptedCiphers([]);
setDecryptedSends([]); setDecryptedSends([]);
clearPasswordSecurityCache();
setUnlockPassword(''); setUnlockPassword('');
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null); setPendingTotpMode(null);
@@ -892,6 +900,7 @@ export default function App() {
setSession(null); setSession(null);
clearProfileSnapshot(); clearProfileSnapshot();
clearOfflineUnlockRecord(); clearOfflineUnlockRecord();
clearPasswordSecurityCache();
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
@@ -1847,6 +1856,8 @@ export default function App() {
}); });
const adminActions = useAdminActions({ const adminActions = useAdminActions({
authedFetch, authedFetch,
email: String(profile?.email || session?.email || ''),
defaultKdfIterations,
onNotify: pushToast, onNotify: pushToast,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchUsers: usersQuery.refetch, refetchUsers: usersQuery.refetch,
@@ -1901,13 +1912,17 @@ export default function App() {
const mobilePrimaryRoute = const mobilePrimaryRoute =
location === '/sends' location === '/sends'
? '/sends' ? '/sends'
: location === '/generator'
? '/generator'
: location === '/vault/totp' : location === '/vault/totp'
? '/vault/totp' ? '/vault/totp'
: location === '/vault' : location === '/vault'
? '/vault' ? '/vault'
: '/settings'; : '/settings';
const currentPageTitle = (() => { const currentPageTitle = (() => {
if (location === '/security/password-health') return t('txt_password_security');
if (location === '/vault/totp') return t('txt_verification_code'); if (location === '/vault/totp') return t('txt_verification_code');
if (location === '/generator') return t('txt_password_generator');
if (location === '/sends') return t('nav_sends'); if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel'); if (location === '/admin') return t('nav_admin_panel');
if (location === '/logs') return t('nav_log_center'); if (location === '/logs') return t('nav_log_center');
@@ -1,4 +1,4 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
@@ -55,7 +55,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isDomainRulesRoute = props.location === '/settings/domain-rules'; const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs'; const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile); const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp'; const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive; const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
const flatSettingsActive = settingsActive && !deviceManagementActive; const flatSettingsActive = settingsActive && !deviceManagementActive;
@@ -175,6 +175,8 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<> <>
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))} {renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))} {renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))} {renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))} {renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))} {renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
@@ -195,8 +197,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<> <>
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))} {renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))} {renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</> </>
)} )}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))} {renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup( {renderNavGroup(
'settings', 'settings',
@@ -327,6 +331,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<Clock3 size={18} /> <Clock3 size={18} />
<span>{t('txt_verification_code')}</span> <span>{t('txt_verification_code')}</span>
</Link> </Link>
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
<Sparkles size={18} />
<span>{t('nav_generator')}</span>
</Link>
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}> <Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
<SendIcon size={18} /> <SendIcon size={18} />
<span>{t('nav_sends')}</span> <span>{t('nav_sends')}</span>
+33 -4
View File
@@ -12,7 +12,9 @@ export interface AppConfirmState {
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
hideCancel?: boolean; hideCancel?: boolean;
onConfirm: () => void; /** When true, dialog shows a master-password field and passes it to onConfirm. */
requireMasterPassword?: boolean;
onConfirm: (masterPassword?: string) => void;
onCancel?: () => void; onCancel?: () => void;
} }
@@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string {
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false); const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const [confirmPassword, setConfirmPassword] = useState('');
const availableProviders = useMemo( const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders), () => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders] [props.pendingTotpAvailableProviders]
@@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType); const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY; const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN; const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
useEffect(() => { useEffect(() => {
setMethodChooserOpen(false); setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]); }, [props.pendingTotpOpen, props.pendingTotpProviderType]);
useEffect(() => {
setConfirmPassword('');
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={props.confirm?.confirmText} confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText} cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel} hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()} confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
onCancel={props.confirm?.onCancel || props.onCancelConfirm} onConfirm={() => {
/> if (requireMasterPassword && !confirmPassword.trim()) return;
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
setConfirmPassword('');
}}
onCancel={() => {
setConfirmPassword('');
(props.confirm?.onCancel || props.onCancelConfirm)();
}}
>
{requireMasterPassword && (
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={confirmPassword}
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} open={props.pendingTotpOpen}
+17 -1
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'preact/compat'; import { lazy, Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
@@ -13,6 +13,8 @@ import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage')); const VaultPage = lazy(() => import('@/components/VaultPage'));
const SendsPage = lazy(() => import('@/components/SendsPage')); const SendsPage = lazy(() => import('@/components/SendsPage'));
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage')); const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
@@ -207,6 +209,16 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
return ( return (
<Switch> <Switch>
<Route path="/security/password-health">
<Suspense fallback={<RouteContentFallback />}>
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
</Suspense>
</Route>
<Route path="/generator">
<Suspense fallback={<RouteContentFallback />}>
<PasswordGeneratorPage />
</Suspense>
</Route>
<Route path="/sends"> <Route path="/sends">
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<SendsPage <SendsPage
@@ -328,6 +340,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsIcon size={18} /> <SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span> <span>{t('nav_account_settings')}</span>
</Link> </Link>
<Link href="/security/password-health" className="mobile-settings-link">
<ShieldCheck size={18} />
<span>{t('nav_password_security')}</span>
</Link>
<Link href="/settings/security/device-management" className="mobile-settings-link"> <Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} /> <Shield size={18} />
<span>{t('nav_device_management')}</span> <span>{t('nav_device_management')}</span>
+35 -2
View File
@@ -1,8 +1,9 @@
import { useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact'; import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import StandalonePageFrame from '@/components/StandalonePageFrame'; import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
interface LoginValues { interface LoginValues {
email: string; email: string;
@@ -81,6 +82,36 @@ function PasswordField(props: {
); );
} }
function OfflineModeNotice() {
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
useEffect(() => subscribeNetworkStatus(setStatus), []);
if (status !== 'offline') return null;
return (
<div className="offline-mode-notice" role="alert" aria-live="assertive">
<div>
<strong>{t('txt_offline_mode_notice_title')}</strong>
<div className="offline-shortcut-list">
<div className="offline-shortcut-row">
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_windows')}</span>
<span className="offline-shortcut-value">
<span className="offline-shortcut-chord"><kbd>Ctrl</kbd><span>+</span><kbd>F5</kbd></span>
</span>
</div>
<div className="offline-shortcut-row">
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_macos')}</span>
<span className="offline-shortcut-value">
<span className="offline-shortcut-chord"><kbd>Command</kbd><span>+</span><kbd>Shift</kbd><span>+</span><kbd>R</kbd></span>
</span>
</div>
</div>
</div>
</div>
);
}
export default function AuthViews(props: AuthViewsProps) { export default function AuthViews(props: AuthViewsProps) {
const loginBusy = props.pendingAction === 'login'; const loginBusy = props.pendingAction === 'login';
const passkeyBusy = props.pendingAction === 'passkey'; const passkeyBusy = props.pendingAction === 'passkey';
@@ -99,6 +130,7 @@ export default function AuthViews(props: AuthViewsProps) {
props.onSubmitUnlock(); props.onSubmitUnlock();
}} }}
> >
<OfflineModeNotice />
<p className="muted standalone-muted">{props.emailForLock}</p> <p className="muted standalone-muted">{props.emailForLock}</p>
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" /> <input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
<PasswordField <PasswordField
@@ -245,6 +277,7 @@ export default function AuthViews(props: AuthViewsProps) {
props.onSubmitLogin(); props.onSubmitLogin();
}} }}
> >
<OfflineModeNotice />
{passkeyPasswordPending ? ( {passkeyPasswordPending ? (
<> <>
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p> <p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import { EFFLongWordList } from '@/lib/eff-word-list';
import { t } from '@/lib/i18n';
type GeneratorMode = 'password' | 'passphrase';
interface PasswordOptions {
length: number;
uppercase: boolean;
lowercase: boolean;
numbers: boolean;
special: boolean;
minNumbers: number;
minSpecial: number;
avoidAmbiguous: boolean;
}
interface PassphraseOptions {
words: number;
separator: string;
capitalize: boolean;
includeNumber: boolean;
}
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
const DIGITS = '0123456789';
const SPECIAL = '!@#$%^&*';
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
const defaultPasswordOptions: PasswordOptions = {
length: 14,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
minNumbers: 1,
minSpecial: 1,
avoidAmbiguous: false,
};
const defaultPassphraseOptions: PassphraseOptions = {
words: 6,
separator: '-',
capitalize: false,
includeNumber: false,
};
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
}
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
try {
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
return {
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
password: {
...defaultPasswordOptions,
...stored.password,
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
},
passphrase: {
...defaultPassphraseOptions,
...stored.passphrase,
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
},
};
} catch {
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
}
}
function randomIndex(length: number): number {
const range = 0x1_0000_0000;
const upperBound = Math.floor(range / length) * length;
const buffer = new Uint32Array(1);
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
return buffer[0] % length;
}
function pick(characters: string): string {
return characters[randomIndex(characters.length)];
}
function shuffle(value: string[]): string[] {
for (let index = value.length - 1; index > 0; index -= 1) {
const next = randomIndex(index + 1);
[value[index], value[next]] = [value[next], value[index]];
}
return value;
}
function filtered(characters: string, avoidAmbiguous: boolean): string {
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
}
function generatePassword(options: PasswordOptions): string {
const sets: Array<{ chars: string; minimum: number }> = [];
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
const length = Math.max(options.length, minimumLength, 5);
const allCharacters = sets.map((set) => set.chars).join('');
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
while (characters.length < length) characters.push(pick(allCharacters));
return shuffle(characters).join('');
}
function generatePassphrase(options: PassphraseOptions): string {
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
if (options.capitalize) {
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
}
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
return words.join(options.separator);
}
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
}
export default function PasswordGeneratorPage() {
const initial = useMemo(readSettings, []);
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
const [seed, setSeed] = useState(0);
const [copied, setCopied] = useState(false);
const generated = useMemo(
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
[mode, passwordOptions, passphraseOptions, seed]
);
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
useEffect(() => {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
} catch {
// The generator remains fully usable when browser storage is unavailable.
}
}, [mode, passwordOptions, passphraseOptions]);
const regenerate = () => {
setCopied(false);
setSeed((value) => value + 1);
};
const copy = async () => {
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
window.setTimeout(() => setCopied(false), 1600);
};
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
setPasswordOptions((current) => ({ ...current, [key]: value }));
setCopied(false);
};
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
setPassphraseOptions((current) => ({ ...current, [key]: value }));
setCopied(false);
};
return (
<section className="generator-page" aria-label={t('txt_password_generator')}>
<div className="generator-layout">
<section className="generator-output-card" aria-live="polite">
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
</div>
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
<div className="generator-strength-row">
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
</div>
<span><ShieldCheck size={15} /> {strength.label}</span>
</div>
<div className="actions generator-actions">
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
</div>
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
</section>
<section className="generator-options-card" aria-labelledby="generator-options-title">
<h2 id="generator-options-title">{t('txt_options')}</h2>
{mode === 'password' ? (
<>
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
</fieldset>
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
</>
) : (
<>
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
<div className="generator-option-group">
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
</div>
</>
)}
</section>
</div>
</section>
);
}
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
}
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
const id = `generator-stepper-${props.id}`;
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
return (
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
<label htmlFor={id}>{props.label}</label>
<div className="generator-stepper">
<button type="button" aria-label={`${props.label} -`} disabled={props.value <= props.minimum} onClick={() => setValue(props.value - 1)}><Minus size={15} /></button>
<input id={id} className="input" type="text" inputMode="numeric" pattern="[0-9]*" value={props.value} onInput={(event) => setValue(Number(event.currentTarget.value))} />
<button type="button" aria-label={`${props.label} +`} disabled={props.value >= props.maximum} onClick={() => setValue(props.value + 1)}><Plus size={15} /></button>
</div>
</div>
);
}
@@ -0,0 +1,169 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact';
import { Link } from 'wouter';
import { maskSecret } from '@/components/vault/vault-page-helpers';
import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
interface PasswordSecurityPageProps {
ciphers: Cipher[];
loading: boolean;
}
type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all';
function vaultFingerprint(ciphers: Cipher[]): string {
return JSON.stringify(ciphers.map((cipher) => ({
id: cipher.id,
type: cipher.type,
revisionDate: cipher.revisionDate || '',
deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '',
})));
}
function formatCheckedAt(value: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
}
export default function PasswordSecurityPage(props: PasswordSecurityPageProps) {
const fingerprint = vaultFingerprint(props.ciphers);
const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint));
const [filter, setFilter] = useState<PasswordSecurityFilter>('all');
const [revealedPasswordIds, setRevealedPasswordIds] = useState<Set<string>>(() => new Set());
useEffect(() => {
setSecurityState(getPasswordSecurityState(fingerprint));
setFilter('all');
setRevealedPasswordIds(new Set());
return subscribePasswordSecurityState(() => {
const next = readPasswordSecurityState(fingerprint);
if (next) setSecurityState(next);
});
}, [fingerprint]);
const { report, scannedAt, scanning, progress, scanError } = securityState;
const eligibleCount = useMemo(
() => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length,
[props.ciphers],
);
const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]);
const filteredItems = useMemo(() => {
if (!report || filter === 'all') return report?.items || [];
if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0);
if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1);
return report.items.filter((item) => item.weak);
}, [filter, report]);
const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId));
const togglePasswordVisibility = (cipherId: string) => {
setRevealedPasswordIds((current) => {
const next = new Set(current);
if (next.has(cipherId)) next.delete(cipherId);
else next.add(cipherId);
return next;
});
};
const toggleAllPasswordVisibility = () => {
if (!report) return;
setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId)));
};
const scan = () => {
setRevealedPasswordIds(new Set());
setFilter('all');
startPasswordSecurityScan(fingerprint, props.ciphers);
};
return (
<section className="password-security-page" aria-label={t('txt_password_security')}>
<div className="password-security-intro card">
<div className="password-security-intro-icon"><ShieldCheck size={22} /></div>
<div>
<h2>{t('txt_password_security')}</h2>
<p>{t('txt_password_security_privacy')}</p>
{scannedAt && <p className="password-security-checked-at">{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}</p>}
</div>
<div className="password-security-intro-actions">
{report && <button type="button" className="btn btn-secondary password-security-toggle-all" onClick={toggleAllPasswordVisibility}>
{allPasswordsVisible ? <EyeOff size={16} className="btn-icon" /> : <Eye size={16} className="btn-icon" />}
{allPasswordsVisible ? t('txt_password_security_hide_all') : t('txt_password_security_show_all')}
</button>}
<button type="button" className="btn btn-primary password-security-scan" disabled={props.loading || scanning || eligibleCount === 0} onClick={scan}>
{scanning ? <RefreshCw size={16} className="btn-icon spin" /> : <ScanSearch size={16} className="btn-icon" />}
{scanning ? t('txt_checking_password_security') : report ? t('txt_recheck_password_security') : t('txt_check_password_security')}
</button>
</div>
</div>
{!report && !scanning && !props.loading && (
<div className="password-security-empty card">
<ShieldCheck size={26} aria-hidden="true" />
<strong>{eligibleCount ? t('txt_password_security_ready') : t('txt_password_security_no_login')}</strong>
<span>{eligibleCount ? t('txt_password_security_manual') : t('txt_password_security_no_login_help')}</span>
</div>
)}
{(scanning || report) && (
<div className="password-security-summary" aria-live="polite">
<SecurityMetric icon={<ShieldAlert size={18} />} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} />
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} />
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} />
<SecurityMetric icon={<CheckCircle2 size={18} />} tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} />
</div>
)}
{scanError && <div className="password-security-notice warning card" role="alert"><Unplug size={16} />{t('txt_password_security_check_failed')}</div>}
{report && (
<section className="password-security-results card">
{report.unavailableCount > 0 && (
<div className="password-security-notice warning"><Unplug size={16} />{t('txt_password_security_unavailable', { count: report.unavailableCount })}</div>
)}
{!report.items.length ? (
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks')}</strong></div>
) : !filteredItems.length ? (
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks_in_filter')}</strong></div>
) : (
<div className="password-security-list">
{filteredItems.map((item) => {
const cipher = ciphersById.get(item.cipherId);
const name = String(cipher?.decName || cipher?.name || '');
const password = String(cipher?.login?.decPassword || '');
const passwordVisible = revealedPasswordIds.has(item.cipherId);
return <article className="password-security-item" key={item.cipherId}>
<div className="password-security-item-main">
<div className="password-security-item-header">
<strong>{name || t('txt_no_name')}</strong>
<div className="password-security-badges">
{item.exposedCount === null && <span className="risk-badge muted">{t('txt_password_security_not_checked')}</span>}
{(item.exposedCount || 0) > 0 && <span className="risk-badge danger">{t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}</span>}
{item.weak && <span className="risk-badge weak">{t('txt_password_security_weak_short')}</span>}
{item.reusedCount > 1 && <span className="risk-badge reused">{t('txt_password_security_reused_short')}</span>}
</div>
</div>
<span className="password-security-password">{passwordVisible ? password : maskSecret(password)}</span>
</div>
<div className="password-security-item-actions">
<button type="button" className="btn btn-secondary small" onClick={() => togglePasswordVisibility(item.cipherId)}>
{passwordVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{passwordVisible ? t('txt_hide') : t('txt_reveal')}
</button>
<Link href={`/vault?cipher=${encodeURIComponent(item.cipherId)}`} className="btn btn-secondary small password-security-open">
<ExternalLink size={14} className="btn-icon" />{t('txt_password_security_jump')}
</Link>
</div>
</article>;
})}
</div>
)}
</section>
)}
</section>
);
}
function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) {
return <button type="button" className={`password-security-metric ${props.tone}`} aria-pressed={props.active} disabled={props.disabled} onClick={props.onClick}><span>{props.icon}</span><div><strong>{props.value}</strong><small>{props.label}</small></div></button>;
}
+2 -2
View File
@@ -130,7 +130,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]); const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false); const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey')); const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false); const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(true);
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null); const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false); const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState(''); const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
@@ -509,7 +509,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setCreatePasskeyDialogOpen(false); setCreatePasskeyDialogOpen(false);
setCreatePasskeyMasterPassword(''); setCreatePasskeyMasterPassword('');
setAccountPasskeyName(t('txt_account_passkey')); setAccountPasskeyName(t('txt_account_passkey'));
setAccountPasskeyDirectUnlock(false); setAccountPasskeyDirectUnlock(true);
} }
async function submitCreatePasskeyDialog(): Promise<void> { async function submitCreatePasskeyDialog(): Promise<void> {
+53 -1
View File
@@ -87,6 +87,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' }); const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const pendingFocusCipherIdRef = useRef<string | null>(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [createMenuOpen, setCreateMenuOpen] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -497,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({}); if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]); }, [sidebarFilter.kind, duplicateMode]);
useEffect(() => {
if (typeof window === 'undefined') return;
const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim();
if (!focusId) return;
pendingFocusCipherIdRef.current = focusId;
}, []);
useEffect(() => {
const focusId = pendingFocusCipherIdRef.current;
if (!focusId) return;
const cipher = cipherById.get(focusId);
if (!cipher) {
if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null;
return;
}
const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher)
? { kind: 'trash' }
: isCipherVisibleInArchive(cipher)
? { kind: 'archive' }
: { kind: 'all' };
setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter));
setSearchInput('');
setSearchQuery('');
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}, [cipherById, props.ciphers.length, props.loading]);
useEffect(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
const focusId = pendingFocusCipherIdRef.current;
if (focusId) {
if (!filteredCipherIds.has(focusId)) return;
setSelectedCipherId(focusId);
setRepromptApprovedCipherId(null);
setShowPassword(false);
setHiddenFieldVisibleMap({});
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
pendingFocusCipherIdRef.current = null;
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
const url = new URL(window.location.href);
if (url.searchParams.has('cipher')) {
url.searchParams.delete('cipher');
const next = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(null, '', next || '/vault');
}
}
return;
}
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
if (selectedCipherId) setSelectedCipherId(''); if (selectedCipherId) setSelectedCipherId('');
return; return;
@@ -506,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) {
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) { if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id); setSelectedCipherId(filteredCiphers[0].id);
} }
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]); }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]); const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
const virtualRange = useMemo(() => { const virtualRange = useMemo(() => {
@@ -1,8 +1,9 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact'; import { AlertTriangle, Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RefreshCw, RotateCcw, ShieldCheck, ShieldAlert, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { TotpCodeResult } from '@/lib/crypto'; import type { TotpCodeResult } from '@/lib/crypto';
import { checkPasswordLeaked, type PasswordBreachResult } from '@/lib/password-security';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -21,6 +22,10 @@ import {
toBooleanFieldValue, toBooleanFieldValue,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
function isAbortError(error: unknown): boolean {
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
}
interface VaultDetailViewProps { interface VaultDetailViewProps {
selectedCipher: Cipher; selectedCipher: Cipher;
repromptApprovedCipherId: string | null; repromptApprovedCipherId: string | null;
@@ -90,6 +95,9 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false); const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const [breachResult, setBreachResult] = useState<PasswordBreachResult | null>(null);
const [checkingBreach, setCheckingBreach] = useState(false);
const breachControllerRef = useRef<AbortController | null>(null);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const isDeleted = isCipherDeleted(props.selectedCipher); const isDeleted = isCipherDeleted(props.selectedCipher);
const passwordHistoryEntries = useMemo( const passwordHistoryEntries = useMemo(
@@ -103,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
[props.selectedCipher.passwordHistory] [props.selectedCipher.passwordHistory]
); );
useEffect(() => { useEffect(() => {
breachControllerRef.current?.abort();
breachControllerRef.current = null;
setShowSshPrivateKey(false); setShowSshPrivateKey(false);
setPasswordHistoryOpen(false); setPasswordHistoryOpen(false);
}, [props.selectedCipher.id]); setBreachResult(null);
setCheckingBreach(false);
return () => {
breachControllerRef.current?.abort();
breachControllerRef.current = null;
};
}, [props.selectedCipher.id, props.selectedCipher.login?.decPassword]);
const checkBreach = async () => {
const password = String(props.selectedCipher.login?.decPassword || '');
if (!password) return;
breachControllerRef.current?.abort();
const controller = new AbortController();
breachControllerRef.current = controller;
setCheckingBreach(true);
setBreachResult(null);
try {
const result = await checkPasswordLeaked(password, fetch, controller.signal);
if (controller.signal.aborted) return;
setBreachResult(result);
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) return;
setBreachResult({ count: null, available: false });
} finally {
if (breachControllerRef.current === controller) {
breachControllerRef.current = null;
setCheckingBreach(false);
}
}
};
const formatDownloadLabel = (attachmentId: string) => { const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -172,8 +210,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}> <button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')} <Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button> </button>
<button type="button" className="btn btn-secondary small" disabled={checkingBreach || !props.selectedCipher.login?.decPassword} onClick={() => void checkBreach()}>
{checkingBreach ? <RefreshCw size={14} className="btn-icon spin" /> : <ShieldCheck size={14} className="btn-icon" />}
{checkingBreach ? t('txt_checking_password_security') : t('txt_check_password_breach')}
</button>
</div> </div>
</div> </div>
{breachResult && (
<div className={`password-breach-inline ${breachResult.available ? (breachResult.count ? 'danger' : 'safe') : 'warning'}`} role="status">
{breachResult.available ? (breachResult.count ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />) : <AlertTriangle size={15} />}
<span>{breachResult.available ? (breachResult.count ? t('txt_password_exposed_count', { count: breachResult.count }) : t('txt_password_not_found_in_breaches')) : t('txt_password_security_check_failed')}</span>
</div>
)}
{!!props.selectedCipher.login.decTotp && ( {!!props.selectedCipher.login.decTotp && (
<div className="kv-row"> <div className="kv-row">
<span className="kv-label">{t('txt_totp')}</span> <span className="kv-label">{t('txt_totp')}</span>
@@ -17,11 +17,13 @@ import {
LayoutGrid, LayoutGrid,
Pencil, Pencil,
ShieldUser, ShieldUser,
ShieldCheck,
Star, Star,
StickyNote, StickyNote,
Trash2, Trash2,
X, X,
} from 'lucide-preact'; } from 'lucide-preact';
import { Link } from 'wouter';
import type { Folder } from '@/lib/types'; import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers'; import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
@@ -95,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
</div> </div>
)} )}
<div className="sidebar-block"> <div className="sidebar-block">
<Link href="/security/password-health" className="tree-btn">
<ShieldCheck size={14} className="tree-icon" /> <span className="tree-label">{t('nav_password_security')}</span>
</Link>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span> <LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
</button> </button>
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
openRemoveAllDevices() { openRemoveAllDevices() {
onSetConfirm({ onSetConfirm({
title: t('txt_remove_all_devices'), title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'), message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllAuthorizedDevices(authedFetch); if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
await deleteAllAuthorizedDevices(authedFetch, derived.hash);
onNotify('success', t('txt_all_devices_removed')); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow(); onLogoutNow();
} catch (error) { } catch (error) {
+80 -28
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin'; import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
import { deriveLoginHash } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared'; import type { AuthedFetch } from '@/lib/api/shared';
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
interface UseAdminActionsOptions { interface UseAdminActionsOptions {
authedFetch: AuthedFetch; authedFetch: AuthedFetch;
email: string;
defaultKdfIterations: number;
onNotify: Notify; onNotify: Notify;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
refetchUsers: () => Promise<unknown>; refetchUsers: () => Promise<unknown>;
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
} }
export default function useAdminActions(options: UseAdminActionsOptions) { export default function useAdminActions(options: UseAdminActionsOptions) {
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options; const {
authedFetch,
email,
defaultKdfIterations,
onNotify,
onSetConfirm,
refetchUsers,
refetchInvites,
} = options;
async function withMasterPasswordHash(masterPassword: string | undefined): Promise<string> {
const normalizedEmail = String(email || '').trim().toLowerCase();
const normalizedPassword = String(masterPassword || '');
if (!normalizedEmail) throw new Error(t('txt_profile_unavailable'));
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
return useMemo( return useMemo(
() => ({ () => ({
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}, },
async createInvite(hours: number) { async createInvite(hours: number) {
try { onSetConfirm({
await createInvite(authedFetch, hours); title: t('txt_create_timed_invite'),
await refetchInvites(); message: t('txt_enter_master_password_to_continue'),
onNotify('success', t('txt_invite_created')); requireMasterPassword: true,
} catch (error) { onConfirm: (masterPassword) => {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed')); onSetConfirm(null);
} void (async () => {
try {
const hash = await withMasterPasswordHash(masterPassword);
await createInvite(authedFetch, hours, hash);
await refetchInvites();
onNotify('success', t('txt_invite_created'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
}
})();
},
});
}, },
async toggleUserStatus(userId: string, status: 'active' | 'banned') { async toggleUserStatus(userId: string, status: 'active' | 'banned') {
try { const nextStatus = status === 'active' ? 'banned' : 'active';
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); onSetConfirm({
await refetchUsers(); title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
onNotify('success', t('txt_user_status_updated')); message: t('txt_enter_master_password_to_continue'),
} catch (error) { danger: nextStatus === 'banned',
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed')); requireMasterPassword: true,
} onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try {
const hash = await withMasterPasswordHash(masterPassword);
await setUserStatus(authedFetch, userId, nextStatus, hash);
await refetchUsers();
onNotify('success', t('txt_user_status_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
}
})();
},
});
}, },
async deleteInvite(code: string) { async deleteInvite(code: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invite'), title: t('txt_delete_invite'),
message: t('txt_delete_invite_confirm_message'), message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvite(authedFetch, code); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvite(authedFetch, code, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_deleted')); onNotify('success', t('txt_invite_deleted'));
} catch (error) { } catch (error) {
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteInvalidInvites() { async deleteInvalidInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invalid_invites'), title: t('txt_delete_invalid_invites'),
message: t('txt_delete_invalid_invites_confirm_message'), message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvalidInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvalidInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invalid_invites_deleted')); onNotify('success', t('txt_invalid_invites_deleted'));
} catch (error) { } catch (error) {
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteAllInvites() { async deleteAllInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_all_invites'), title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'), message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteAllInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_all_invites_deleted')); onNotify('success', t('txt_all_invites_deleted'));
} catch (error) { } catch (error) {
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteUser(userId: string) { async deleteUser(userId: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_user'), title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'), message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteUser(authedFetch, userId); const hash = await withMasterPasswordHash(masterPassword);
await deleteUser(authedFetch, userId, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_deleted')); onNotify('success', t('txt_user_deleted'));
} catch (error) { } catch (error) {
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}); });
}, },
}), }),
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers] [authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
); );
} }
+32 -118
View File
@@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential {
deviceResponse: PublicKeyCredential; deviceResponse: PublicKeyCredential;
request: Record<string, unknown>; request: Record<string, unknown>;
supportsPrf: boolean; supportsPrf: boolean;
prfKey?: Uint8Array;
} }
export interface AccountPasskeyPrfKeySet { export interface AccountPasskeyPrfKeySet {
@@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise<Uint8Array> {
return new Uint8Array(hash); return new Uint8Array(hash);
} }
function credentialIdToBase64Url(id: BufferSource): string | null {
try {
const bytes = id instanceof ArrayBuffer
? new Uint8Array(id)
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
return bytesToBase64Url(bytes);
} catch {
return null;
}
}
type PrfEvalInput = { first: Uint8Array }; type PrfEvalInput = { first: Uint8Array };
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> { function buildPrfExtension(salt: Uint8Array): Record<string, unknown> {
const evalInput: PrfEvalInput = { first: salt }; const evalInput: PrfEvalInput = { first: salt };
return { return {
prf: { prf: {
@@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
}; };
} }
function buildCredentialPrfExtension( function withPrfExtension(
salt: Uint8Array, options: PublicKeyCredentialCreationOptions,
credentialIds: Array<string | null | undefined> salt: Uint8Array
): Record<string, unknown> { ): PublicKeyCredentialCreationOptions;
const evalInput = { first: salt };
const evalByCredential = credentialIds
.filter((id): id is string => !!id)
.reduce<Record<string, PrfEvalInput>>((out, id) => {
out[id] = evalInput;
return out;
}, {});
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
return {
prf: {
evalByCredential,
},
};
}
function withPrfExtension( function withPrfExtension(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
extension: Record<string, unknown> salt: Uint8Array
): PublicKeyCredentialRequestOptions { ): PublicKeyCredentialRequestOptions;
function withPrfExtension(
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
salt: Uint8Array
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
return { return {
...options, ...options,
extensions: { extensions: {
...((options as any).extensions || {}), ...((options as any).extensions || {}),
...extension, ...buildPrfExtension(salt),
} as any, } as any,
}; };
} }
@@ -154,70 +133,17 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde
return result instanceof ArrayBuffer ? result : undefined; return result instanceof ArrayBuffer ? result : undefined;
} }
function hasPrfExtensionResult(credential: PublicKeyCredential): boolean {
return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf');
}
function shouldRetryWithLegacyPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
}
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
return (
name === 'NotSupportedError' ||
name === 'SyntaxError' ||
name === 'TypeError' ||
(name === 'UnknownError' && /transient/i.test(message))
);
}
async function canRequestPrfExtension(): Promise<boolean> {
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
return true;
}
async function getPublicKeyCredentialWithPrf( async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
salt: Uint8Array, salt: Uint8Array
credentialIds: string[] = []
): Promise<PublicKeyCredential> { ): Promise<PublicKeyCredential> {
const attempts = credentialIds.length const credential = await navigator.credentials.get({
? [ publicKey: withPrfExtension(options, salt),
buildCredentialPrfExtension(salt, credentialIds), });
buildLegacyPrfExtension(salt), if (!(credential instanceof PublicKeyCredential)) {
] throw new Error(t('txt_no_passkey_selected'));
: [buildLegacyPrfExtension(salt)];
let lastCredential: PublicKeyCredential | null = null;
for (let index = 0; index < attempts.length; index += 1) {
try {
const credential = await navigator.credentials.get({
publicKey: withPrfExtension(options, attempts[index]),
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_selected'));
}
lastCredential = credential;
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
return credential;
}
} catch (error) {
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
if (lastCredential) return lastCredential;
throw error;
}
}
} }
if (lastCredential) return lastCredential; return credential;
throw new Error(t('txt_no_passkey_selected'));
}
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
return (options.allowCredentials || [])
.map((credential) => credentialIdToBase64Url(credential.id))
.filter((id): id is string => !!id);
} }
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> { async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
@@ -282,8 +208,7 @@ export async function assertAccountPasskey(
const nativeOptions = cloneRequestOptions(response.options); const nativeOptions = cloneRequestOptions(response.options);
const credential = await getPublicKeyCredentialWithPrf( const credential = await getPublicKeyCredentialWithPrf(
nativeOptions, nativeOptions,
await getLoginWithPrfSalt(), await getLoginWithPrfSalt()
prfCredentialIdsFromAllowCredentials(nativeOptions)
); );
const prfResult = readPrfFirstResult(credential); const prfResult = readPrfFirstResult(credential);
return { return {
@@ -309,34 +234,22 @@ export async function createAccountPasskeyCredential(
} }
return credential; return credential;
}; };
let credential: PublicKeyCredential; const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null;
if (requestPrf && await canRequestPrfExtension()) { const credential = await createWithOptions(
const prfOptions: PublicKeyCredentialCreationOptions = { prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions
...noPrfOptions, );
extensions: {
...((noPrfOptions as any).extensions || {}),
prf: {},
} as any,
};
try {
credential = await createWithOptions(prfOptions);
} catch (error) {
if (!shouldRetryCreateWithoutPrf(error)) throw error;
credential = await createWithOptions(noPrfOptions);
}
} else {
credential = await createWithOptions(noPrfOptions);
}
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created')); throw new Error(t('txt_no_passkey_created'));
} }
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled; const prfResult = readPrfFirstResult(credential);
const supportsPrf = !!prfResult || (credential.getClientExtensionResults() as any).prf?.enabled === true;
return { return {
token: response.token, token: response.token,
createOptions: nativeOptions, createOptions: nativeOptions,
deviceResponse: credential, deviceResponse: credential,
request: attestationRequest(credential), request: attestationRequest(credential),
supportsPrf, supportsPrf,
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
}; };
} }
@@ -373,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet(
pending: PendingAccountPasskeyCredential, pending: PendingAccountPasskeyCredential,
userKey: { symEncKey: string; symMacKey: string } userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> { ): Promise<AccountPasskeyPrfKeySet> {
if (pending.prfKey) {
return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey);
}
const rawId = new Uint8Array(pending.deviceResponse.rawId); const rawId = new Uint8Array(pending.deviceResponse.rawId);
const credentialId = bytesToBase64Url(rawId);
const assertionOptions: PublicKeyCredentialRequestOptions = { const assertionOptions: PublicKeyCredentialRequestOptions = {
challenge: pending.createOptions?.challenge!, challenge: pending.createOptions?.challenge!,
rpId: pending.createOptions?.rp?.id, rpId: pending.createOptions?.rp?.id,
@@ -384,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet(
}; };
const assertion = await getPublicKeyCredentialWithPrf( const assertion = await getPublicKeyCredentialWithPrf(
assertionOptions, assertionOptions,
await getLoginWithPrfSalt(), await getLoginWithPrfSalt()
[credentialId]
); );
const prfResult = readPrfFirstResult(assertion); const prfResult = readPrfFirstResult(assertion);
if (!prfResult) { if (!prfResult) {
+29 -12
View File
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
return body?.data || []; return body?.data || [];
} }
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> { export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { const resp = await authedFetch('/api/admin/invites', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInHours: hours }), body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Create invite failed'); if (!resp.ok) throw new Error('Create invite failed');
} }
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> { export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invite failed'); if (!resp.ok) throw new Error('Delete invite failed');
} }
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites?scope=invalid', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invalid invites failed'); if (!resp.ok) throw new Error('Delete invalid invites failed');
} }
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete all invites failed'); if (!resp.ok) throw new Error('Delete all invites failed');
} }
export async function setUserStatus( export async function setUserStatus(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
userId: string, userId: string,
status: 'active' | 'banned' status: 'active' | 'banned',
masterPasswordHash: string
): Promise<void> { ): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, { const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }), body: JSON.stringify({ status, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Update user status failed'); if (!resp.ok) throw new Error('Update user status failed');
} }
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> { export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete user failed'); if (!resp.ok) throw new Error('Delete user failed');
} }
+6 -2
View File
@@ -1140,8 +1140,12 @@ export async function updateAuthorizedDeviceName(
if (!resp.ok) throw new Error(t('txt_update_device_note_failed')); if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
} }
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' }); const resp = await authedFetch('/api/devices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
} }
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -11,7 +11,11 @@ export type Locale =
| 'zh-TW' | 'zh-TW'
| 'ru' | 'ru'
| 'es' | 'es'
| 'fi'; | 'fi'
| 'de'
| 'fr'
| 'it'
| 'sv';
import enMessages from './i18n/locales/en'; import enMessages from './i18n/locales/en';
const LOCALE_STORAGE_KEY = 'nodewarden.locale'; const LOCALE_STORAGE_KEY = 'nodewarden.locale';
@@ -25,6 +29,10 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
{ value: 'ru', label: 'Русский' }, { value: 'ru', label: 'Русский' },
{ value: 'es', label: 'Español' }, { value: 'es', label: 'Español' },
{ value: 'fi', label: 'Suomi' }, { value: 'fi', label: 'Suomi' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'sv', label: 'Svenska' },
]; ];
let locale: Locale = resolveInitialLocale(); let locale: Locale = resolveInitialLocale();
@@ -51,6 +59,10 @@ function resolveInitialLocale(): Locale {
if (normalized.startsWith('ru')) return 'ru'; if (normalized.startsWith('ru')) return 'ru';
if (normalized.startsWith('es')) return 'es'; if (normalized.startsWith('es')) return 'es';
if (normalized.startsWith('fi')) return 'fi'; if (normalized.startsWith('fi')) return 'fi';
if (normalized.startsWith('de')) return 'de';
if (normalized.startsWith('fr')) return 'fr';
if (normalized.startsWith('it')) return 'it';
if (normalized.startsWith('sv')) return 'sv';
} }
} }
return 'en'; return 'en';
@@ -63,6 +75,10 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
ru: () => import('./i18n/locales/ru'), ru: () => import('./i18n/locales/ru'),
es: () => import('./i18n/locales/es'), es: () => import('./i18n/locales/es'),
fi: () => import('./i18n/locales/fi'), fi: () => import('./i18n/locales/fi'),
de: () => import('./i18n/locales/de'),
fr: () => import('./i18n/locales/fr'),
it: () => import('./i18n/locales/it'),
sv: () => import('./i18n/locales/sv'),
}; };
function localeToHtmlLang(value: Locale): string { function localeToHtmlLang(value: Locale): string {
File diff suppressed because it is too large Load Diff
+62
View File
@@ -7,6 +7,30 @@ const en: Record<string, string> = {
"nav_my_vault": "My Vault", "nav_my_vault": "My Vault",
"nav_vault_items": "Vault", "nav_vault_items": "Vault",
"nav_sends": "Sends", "nav_sends": "Sends",
"nav_generator": "Generator",
"txt_password_generator": "Password Generator",
"txt_password_generator_description": "Create a strong, unique password locally on this device.",
"txt_generator_type": "Generator type",
"txt_passphrase": "Passphrase",
"txt_generated_password": "Generated password",
"txt_password_strength": "Strength",
"txt_password_strength_weak": "Weak",
"txt_password_strength_fair": "Fair",
"txt_password_strength_good": "Good",
"txt_password_strength_strong": "Strong",
"txt_generator_security_note": "Generation happens locally. Your generated password is never sent to the server.",
"txt_generator_length": "Length",
"txt_generator_character_types": "Character types",
"txt_generator_uppercase": "Uppercase (A-Z)",
"txt_generator_lowercase": "Lowercase (a-z)",
"txt_generator_numbers": "Numbers (0-9)",
"txt_generator_special": "Special characters (!@#$%^&*)",
"txt_generator_minimum": "Minimum",
"txt_generator_avoid_ambiguous": "Avoid ambiguous characters",
"txt_generator_words": "Number of words",
"txt_generator_separator": "Word separator",
"txt_generator_capitalize": "Capitalize",
"txt_generator_include_number": "Include a number",
"nav_backup_strategy": "Cloud Backup", "nav_backup_strategy": "Cloud Backup",
"nav_import_export": "Import & Export", "nav_import_export": "Import & Export",
"nav_group_data_backup": "Data & Backup", "nav_group_data_backup": "Data & Backup",
@@ -1012,6 +1036,9 @@ const en: Record<string, string> = {
"txt_online": "Online", "txt_online": "Online",
"txt_offline": "Offline", "txt_offline": "Offline",
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.", "txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
"txt_offline_mode_notice_title": "You are in offline mode. If this looks wrong, force refresh.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Submit", "txt_submit": "Submit",
"txt_sync": "Sync", "txt_sync": "Sync",
"txt_sync_vault": "Sync Vault", "txt_sync_vault": "Sync Vault",
@@ -1444,4 +1471,39 @@ const en: Record<string, string> = {
"txt_ip_address": "IP address" "txt_ip_address": "IP address"
}; };
Object.assign(en, {
"nav_password_security": "Password Security",
"txt_password_security": "Password Security Check",
"txt_password_security_privacy": "Passwords are checked locally in your browser. Only an anonymous hash prefix is sent to the breach database.",
"txt_check_password_security": "Start check",
"txt_checking_password_security": "Checking",
"txt_recheck_password_security": "Check again",
"txt_password_security_ready": "Your vault is ready for a security check.",
"txt_password_security_no_login": "There are no login passwords to check.",
"txt_password_security_manual": "The check only starts when you choose it. Results are kept until you refresh or your vault changes.",
"txt_password_security_no_login_help": "Add a login item with a password, then return here to check it.",
"txt_exposed_passwords": "Exposed",
"txt_reused_passwords": "Reused",
"txt_weak_passwords": "Weak",
"txt_passwords_checked": "Checked",
"txt_password_security_last_checked": "Last checked: {value}",
"txt_password_security_show_all": "Show all",
"txt_password_security_hide_all": "Hide all",
"txt_password_security_jump": "Go to item",
"txt_password_security_exposed_short": "Exposed {count} times",
"txt_password_security_weak_short": "Weak password",
"txt_password_security_reused_short": "Reused",
"txt_password_security_unavailable": "{count} password checks could not reach the breach database. They are not marked safe.",
"txt_password_security_not_checked": "Not checked",
"txt_password_exposed_count": "Found in {count} breaches",
"txt_password_reused_count": "Used {count} times",
"txt_weak_password": "Weak password",
"txt_no_password_risks": "No password risks found",
"txt_no_password_risks_in_filter": "No password risks in this category",
"txt_open_vault": "Open vault",
"txt_check_password_breach": "Check breach",
"txt_password_not_found_in_breaches": "Not found in the breach database",
"txt_password_security_check_failed": "The breach check could not be completed."
});
export default en; export default en;
+12
View File
@@ -7,6 +7,7 @@ const es: Record<string, string> = {
"nav_my_vault": "Mi bóveda", "nav_my_vault": "Mi bóveda",
"nav_vault_items": "Bóveda", "nav_vault_items": "Bóveda",
"nav_sends": "Envíos", "nav_sends": "Envíos",
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
"nav_backup_strategy": "Copia de seguridad en la nube", "nav_backup_strategy": "Copia de seguridad en la nube",
"nav_import_export": "Importar y exportar", "nav_import_export": "Importar y exportar",
"nav_group_data_backup": "Datos y copias", "nav_group_data_backup": "Datos y copias",
@@ -1012,6 +1013,9 @@ const es: Record<string, string> = {
"txt_online": "En línea", "txt_online": "En línea",
"txt_offline": "Sin conexión", "txt_offline": "Sin conexión",
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.", "txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
"txt_offline_mode_notice_title": "Estás en modo sin conexión. Si parece un error, fuerza la recarga.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Enviar", "txt_submit": "Enviar",
"txt_sync": "Sincronizar", "txt_sync": "Sincronizar",
"txt_sync_vault": "Sincronizar bóveda", "txt_sync_vault": "Sincronizar bóveda",
@@ -1444,4 +1448,12 @@ const es: Record<string, string> = {
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública" "txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
}; };
Object.assign(es, {
"nav_password_security": "Seguridad de contraseñas", "txt_password_security": "Comprobación de seguridad", "txt_password_security_privacy": "Las contraseñas se comprueban localmente. Solo se envía un prefijo de hash anónimo a la base de filtraciones al iniciar la comprobación.", "txt_check_password_security": "Iniciar comprobación", "txt_checking_password_security": "Comprobando", "txt_recheck_password_security": "Comprobar de nuevo", "txt_password_security_ready": "Tu bóveda está lista para una comprobación de seguridad.", "txt_password_security_no_login": "No hay contraseñas de inicio de sesión para comprobar.", "txt_password_security_manual": "La comprobación solo empieza cuando la eliges. Los resultados se conservan solo en esta página.", "txt_password_security_no_login_help": "Añade un inicio de sesión con contraseña y vuelve aquí para comprobarlo.", "txt_exposed_passwords": "Filtradas", "txt_reused_passwords": "Reutilizadas", "txt_weak_passwords": "Débiles", "txt_passwords_checked": "Comprobadas", "txt_password_security_unavailable": "{count} comprobaciones no pudieron acceder a la base de filtraciones. No se marcan como seguras.", "txt_password_security_not_checked": "Sin comprobar", "txt_password_exposed_count": "Encontrada en {count} filtraciones", "txt_password_reused_count": "Usada {count} veces", "txt_weak_password": "Contraseña débil", "txt_no_password_risks": "No se encontraron riesgos de contraseña", "txt_open_vault": "Abrir bóveda", "txt_check_password_breach": "Comprobar filtración", "txt_password_not_found_in_breaches": "No encontrada en la base de filtraciones", "txt_password_security_check_failed": "No se pudo completar la comprobación de filtraciones."
});
Object.assign(es, { "txt_password_security_last_checked": "Última comprobación: {value}" });
Object.assign(es, { "txt_no_password_risks_in_filter": "No hay riesgos de contraseña en esta categoría" });
Object.assign(es, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default es; export default es;
+12
View File
@@ -7,6 +7,7 @@ const fi: Record<string, string> = {
"nav_my_vault": "Oma holvi", "nav_my_vault": "Oma holvi",
"nav_vault_items": "Holvi", "nav_vault_items": "Holvi",
"nav_sends": "Lähetykset", "nav_sends": "Lähetykset",
"nav_generator": "Luoja", "txt_password_generator": "Salasanageneraattori", "txt_password_generator_description": "Luo vahva ja yksilöllinen salasana paikallisesti tällä laitteella.", "txt_generator_type": "Generaattorin tyyppi", "txt_passphrase": "Salalause", "txt_generated_password": "Luotu salasana", "txt_password_strength": "Vahvuus", "txt_password_strength_weak": "Heikko", "txt_password_strength_fair": "Kohtalainen", "txt_password_strength_good": "Hyvä", "txt_password_strength_strong": "Vahva", "txt_generator_security_note": "Generointi tapahtuu paikallisesti. Salasanaa ei koskaan lähetetä palvelimelle.", "txt_generator_length": "Pituus", "txt_generator_character_types": "Merkkityypit", "txt_generator_uppercase": "Isot kirjaimet (A-Z)", "txt_generator_lowercase": "Pienet kirjaimet (a-z)", "txt_generator_numbers": "Numerot (0-9)", "txt_generator_special": "Erikoismerkit (!@#$%^&*)", "txt_generator_minimum": "Vähintään", "txt_generator_avoid_ambiguous": "Vältä epäselviä merkkejä", "txt_generator_words": "Sanojen määrä", "txt_generator_separator": "Sanaerotin", "txt_generator_capitalize": "Iso alkukirjain", "txt_generator_include_number": "Sisällytä numero",
"nav_backup_strategy": "Pilvivarmuuskopiointi", "nav_backup_strategy": "Pilvivarmuuskopiointi",
"nav_import_export": "Tuonti ja Vienti", "nav_import_export": "Tuonti ja Vienti",
"nav_group_data_backup": "Data & Varmuuskopiointi", "nav_group_data_backup": "Data & Varmuuskopiointi",
@@ -1012,6 +1013,9 @@ const fi: Record<string, string> = {
"txt_online": "Verkossa", "txt_online": "Verkossa",
"txt_offline": "Offline", "txt_offline": "Offline",
"txt_offline_vault_readonly": "Offline-tila on vain luku -muodossa. Yhdistä NodeWardeniin ennen kuin muutat holviasi.", "txt_offline_vault_readonly": "Offline-tila on vain luku -muodossa. Yhdistä NodeWardeniin ennen kuin muutat holviasi.",
"txt_offline_mode_notice_title": "Olet offline-tilassa. Jos tämä vaikuttaa virheeltä, tee pakotettu päivitys.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Lähetä", "txt_submit": "Lähetä",
"txt_sync": "Synkronoi", "txt_sync": "Synkronoi",
"txt_sync_vault": "Synkronoi holvi", "txt_sync_vault": "Synkronoi holvi",
@@ -1444,4 +1448,12 @@ const fi: Record<string, string> = {
"txt_ip_address": "IP-osoite" "txt_ip_address": "IP-osoite"
}; };
Object.assign(fi, {
"nav_password_security": "Salasanasuojaus", "txt_password_security": "Salasanojen turvatarkistus", "txt_password_security_privacy": "Salasanat tarkistetaan paikallisesti. Vain anonyymi hajautteen alku lähetetään vuototietokantaan tarkistuksen alkaessa.", "txt_check_password_security": "Aloita tarkistus", "txt_checking_password_security": "Tarkistetaan", "txt_recheck_password_security": "Tarkista uudelleen", "txt_password_security_ready": "Holvisi on valmis turvatarkistukseen.", "txt_password_security_no_login": "Tarkistettavia kirjautumissalasanoja ei ole.", "txt_password_security_manual": "Tarkistus käynnistyy vain valinnastasi. Tulokset säilyvät vain tällä sivulla.", "txt_password_security_no_login_help": "Lisää kirjautuminen salasanalla ja palaa sitten tarkistamaan se.", "txt_exposed_passwords": "Vuotaneet", "txt_reused_passwords": "Uudelleenkäytetyt", "txt_weak_passwords": "Heikot", "txt_passwords_checked": "Tarkistettu", "txt_password_security_unavailable": "{count} salasanatarkistusta ei tavoittanut vuototietokantaa. Niitä ei merkitä turvallisiksi.", "txt_password_security_not_checked": "Ei tarkistettu", "txt_password_exposed_count": "Löytyi {count} vuodosta", "txt_password_reused_count": "Käytetty {count} kertaa", "txt_weak_password": "Heikko salasana", "txt_no_password_risks": "Salasanariskejä ei löytynyt", "txt_open_vault": "Avaa holvi", "txt_check_password_breach": "Tarkista vuoto", "txt_password_not_found_in_breaches": "Ei löytynyt vuototietokannasta", "txt_password_security_check_failed": "Vuototarkistusta ei voitu suorittaa."
});
Object.assign(fi, { "txt_password_security_last_checked": "Tarkistettu viimeksi: {value}" });
Object.assign(fi, { "txt_no_password_risks_in_filter": "Tässä luokassa ei ole salasanojen riskejä" });
Object.assign(fi, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default fi; export default fi;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -8,6 +8,7 @@ const ru: Record<string, string> = {
"nav_my_vault": "Мое хранилище", "nav_my_vault": "Мое хранилище",
"nav_vault_items": "Хранилище", "nav_vault_items": "Хранилище",
"nav_sends": "Отправляет", "nav_sends": "Отправляет",
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
"nav_backup_strategy": "Облачное резервное копирование", "nav_backup_strategy": "Облачное резервное копирование",
"nav_import_export": "Импорт и экспорт", "nav_import_export": "Импорт и экспорт",
"nav_group_data_backup": "Данные и резервные копии", "nav_group_data_backup": "Данные и резервные копии",
@@ -1012,6 +1013,9 @@ const ru: Record<string, string> = {
"txt_online": "Онлайн", "txt_online": "Онлайн",
"txt_offline": "Офлайн", "txt_offline": "Офлайн",
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.", "txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
"txt_offline_mode_notice_title": "Вы в автономном режиме. Если это ошибка, выполните принудительное обновление.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Отправить", "txt_submit": "Отправить",
"txt_sync": "Синхронизировать", "txt_sync": "Синхронизировать",
"txt_sync_vault": "Синхронизировать хранилище", "txt_sync_vault": "Синхронизировать хранилище",
@@ -1444,4 +1448,12 @@ const ru: Record<string, string> = {
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ" "txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
}; };
Object.assign(ru, {
"nav_password_security": "Безопасность паролей", "txt_password_security": "Проверка безопасности паролей", "txt_password_security_privacy": "Пароли проверяются локально. После запуска в базу утечек передаётся только анонимный префикс хеша.", "txt_check_password_security": "Начать проверку", "txt_checking_password_security": "Проверка", "txt_recheck_password_security": "Проверить снова", "txt_password_security_ready": "Ваше хранилище готово к проверке безопасности.", "txt_password_security_no_login": "Нет паролей для входа, доступных для проверки.", "txt_password_security_manual": "Проверка запускается только по вашему выбору. Результаты остаются только на этой странице.", "txt_password_security_no_login_help": "Добавьте запись входа с паролем и вернитесь сюда для проверки.", "txt_exposed_passwords": "Скомпрометированы", "txt_reused_passwords": "Повторно используются", "txt_weak_passwords": "Слабые", "txt_passwords_checked": "Проверено", "txt_password_security_unavailable": "{count} проверок не смогли обратиться к базе утечек. Они не помечены безопасными.", "txt_password_security_not_checked": "Не проверено", "txt_password_exposed_count": "Найдено в {count} утечках", "txt_password_reused_count": "Используется {count} раз", "txt_weak_password": "Слабый пароль", "txt_no_password_risks": "Рисков паролей не найдено", "txt_open_vault": "Открыть хранилище", "txt_check_password_breach": "Проверить утечку", "txt_password_not_found_in_breaches": "Не найден в базе утечек", "txt_password_security_check_failed": "Не удалось завершить проверку утечки."
});
Object.assign(ru, { "txt_password_security_last_checked": "Последняя проверка: {value}" });
Object.assign(ru, { "txt_no_password_risks_in_filter": "В этой категории нет рисков для паролей" });
Object.assign(ru, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default ru; export default ru;
File diff suppressed because it is too large Load Diff
+42
View File
@@ -7,6 +7,10 @@ const zhCN: Record<string, string> = {
"nav_my_vault": "我的密码库", "nav_my_vault": "我的密码库",
"nav_vault_items": "密码库", "nav_vault_items": "密码库",
"nav_sends": "Send", "nav_sends": "Send",
"nav_generator": "密码生成器",
"txt_password_generator": "密码生成器",
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。",
"txt_generator_type": "生成类型", "txt_passphrase": "密码短语", "txt_generated_password": "已生成密码", "txt_password_strength": "强度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "一般", "txt_password_strength_good": "良好", "txt_password_strength_strong": "强", "txt_generator_security_note": "生成过程仅在本地进行,密码不会发送到服务器。", "txt_generator_length": "长度", "txt_generator_character_types": "字符类型", "txt_generator_uppercase": "大写字母 (A-Z)", "txt_generator_lowercase": "小写字母 (a-z)", "txt_generator_numbers": "数字 (0-9)", "txt_generator_special": "特殊字符 (!@#$%^&*)", "txt_generator_minimum": "最少数量", "txt_generator_avoid_ambiguous": "避免易混淆字符", "txt_generator_words": "单词数量", "txt_generator_separator": "单词分隔符", "txt_generator_capitalize": "首字母大写", "txt_generator_include_number": "包含数字",
"nav_backup_strategy": "云端备份", "nav_backup_strategy": "云端备份",
"nav_import_export": "导入导出", "nav_import_export": "导入导出",
"nav_group_data_backup": "数据与备份", "nav_group_data_backup": "数据与备份",
@@ -1012,6 +1016,9 @@ const zhCN: Record<string, string> = {
"txt_online": "在线", "txt_online": "在线",
"txt_offline": "离线", "txt_offline": "离线",
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。", "txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
"txt_offline_mode_notice_title": "你正处于离线模式,若误判请强制刷新",
"txt_offline_mode_notice_windows": "Windows",
"txt_offline_mode_notice_macos": "macOS",
"txt_submit": "提交", "txt_submit": "提交",
"txt_sync": "同步", "txt_sync": "同步",
"txt_sync_vault": "同步", "txt_sync_vault": "同步",
@@ -1444,4 +1451,39 @@ const zhCN: Record<string, string> = {
"txt_ip_address": "IP 地址" "txt_ip_address": "IP 地址"
}; };
Object.assign(zhCN, {
"nav_password_security": "安全检测",
"txt_password_security": "安全检测",
"txt_password_security_privacy": "密码仅在本地前端检查;只有匿名哈希前缀会发送到泄露密码库。",
"txt_check_password_security": "开始检查",
"txt_checking_password_security": "检查中",
"txt_recheck_password_security": "重新检查",
"txt_password_security_ready": "密码库已准备好进行安全检查。",
"txt_password_security_no_login": "没有可检查的登录密码。",
"txt_password_security_manual": "仅在您主动开始后才会联网检查;结果会保留到刷新页面或密码库内容变更前。",
"txt_password_security_no_login_help": "添加一个包含密码的登录项目后,再回到此处检查。",
"txt_exposed_passwords": "已泄露",
"txt_reused_passwords": "重复使用",
"txt_weak_passwords": "较弱",
"txt_passwords_checked": "已检查",
"txt_password_security_last_checked": "上次检测:{value}",
"txt_password_security_show_all": "显示全部",
"txt_password_security_hide_all": "隐藏全部",
"txt_password_security_jump": "跳转",
"txt_password_security_exposed_short": "泄露 {count} 次",
"txt_password_security_weak_short": "弱密码",
"txt_password_security_reused_short": "重复",
"txt_password_security_unavailable": "有 {count} 个密码无法连接泄露库,未被标记为安全。",
"txt_password_security_not_checked": "未检查",
"txt_password_exposed_count": "已在 {count} 次泄露中出现",
"txt_password_reused_count": "使用了 {count} 次",
"txt_weak_password": "较弱密码",
"txt_no_password_risks": "未发现密码风险",
"txt_no_password_risks_in_filter": "此类别中没有密码风险",
"txt_open_vault": "打开密码库",
"txt_check_password_breach": "检查泄露",
"txt_password_not_found_in_breaches": "未在泄露密码库中发现",
"txt_password_security_check_failed": "无法完成泄露检查。"
});
export default zhCN; export default zhCN;
+42
View File
@@ -7,6 +7,10 @@ const zhTW: Record<string, string> = {
"nav_my_vault": "我的密碼庫", "nav_my_vault": "我的密碼庫",
"nav_vault_items": "密碼庫", "nav_vault_items": "密碼庫",
"nav_sends": "Send", "nav_sends": "Send",
"nav_generator": "密碼產生器",
"txt_password_generator": "密碼產生器",
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。",
"txt_generator_type": "產生類型", "txt_passphrase": "密碼片語", "txt_generated_password": "已產生密碼", "txt_password_strength": "強度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "普通", "txt_password_strength_good": "良好", "txt_password_strength_strong": "強", "txt_generator_security_note": "產生程序僅在本機進行,密碼不會傳送到伺服器。", "txt_generator_length": "長度", "txt_generator_character_types": "字元類型", "txt_generator_uppercase": "大寫字母 (A-Z)", "txt_generator_lowercase": "小寫字母 (a-z)", "txt_generator_numbers": "數字 (0-9)", "txt_generator_special": "特殊字元 (!@#$%^&*)", "txt_generator_minimum": "最少數量", "txt_generator_avoid_ambiguous": "避免易混淆字元", "txt_generator_words": "單字數量", "txt_generator_separator": "單字分隔符號", "txt_generator_capitalize": "首字母大寫", "txt_generator_include_number": "包含數字",
"nav_backup_strategy": "雲端備份", "nav_backup_strategy": "雲端備份",
"nav_import_export": "導入導出", "nav_import_export": "導入導出",
"nav_group_data_backup": "資料與備份", "nav_group_data_backup": "資料與備份",
@@ -1012,6 +1016,9 @@ const zhTW: Record<string, string> = {
"txt_online": "在線", "txt_online": "在線",
"txt_offline": "離線", "txt_offline": "離線",
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。", "txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
"txt_offline_mode_notice_title": "你正處於離線模式,若誤判請強制重新整理",
"txt_offline_mode_notice_windows": "Windows",
"txt_offline_mode_notice_macos": "macOS",
"txt_submit": "提交", "txt_submit": "提交",
"txt_sync": "同步", "txt_sync": "同步",
"txt_sync_vault": "同步", "txt_sync_vault": "同步",
@@ -1444,4 +1451,39 @@ const zhTW: Record<string, string> = {
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰" "txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
}; };
Object.assign(zhTW, {
"nav_password_security": "密碼安全",
"txt_password_security": "密碼安全檢查",
"txt_password_security_privacy": "密碼僅在本機前端檢查;只有匿名雜湊前綴會傳送到外洩密碼庫。",
"txt_check_password_security": "開始檢查",
"txt_checking_password_security": "檢查中",
"txt_recheck_password_security": "重新檢查",
"txt_password_security_ready": "密碼庫已準備好進行安全檢查。",
"txt_password_security_no_login": "沒有可檢查的登入密碼。",
"txt_password_security_manual": "僅在您主動開始後才會連線檢查;結果會保留到重新整理頁面或密碼庫內容變更前。",
"txt_password_security_no_login_help": "新增一個含有密碼的登入項目後,再回到此處檢查。",
"txt_exposed_passwords": "已外洩",
"txt_reused_passwords": "重複使用",
"txt_weak_passwords": "較弱",
"txt_passwords_checked": "已檢查",
"txt_password_security_last_checked": "上次檢查:{value}",
"txt_password_security_show_all": "顯示全部",
"txt_password_security_hide_all": "隱藏全部",
"txt_password_security_jump": "跳轉",
"txt_password_security_exposed_short": "外洩 {count} 次",
"txt_password_security_weak_short": "弱密碼",
"txt_password_security_reused_short": "重複",
"txt_password_security_unavailable": "有 {count} 個密碼無法連線至外洩資料庫,未被標記為安全。",
"txt_password_security_not_checked": "未檢查",
"txt_password_exposed_count": "已在 {count} 次外洩中出現",
"txt_password_reused_count": "使用了 {count} 次",
"txt_weak_password": "較弱密碼",
"txt_no_password_risks": "未發現密碼風險",
"txt_no_password_risks_in_filter": "此類別中沒有密碼風險",
"txt_open_vault": "開啟密碼庫",
"txt_check_password_breach": "檢查外洩",
"txt_password_not_found_in_breaches": "未在外洩密碼庫中發現",
"txt_password_security_check_failed": "無法完成外洩檢查。"
});
export default zhTW; export default zhTW;
+74
View File
@@ -0,0 +1,74 @@
import { inspectVaultPasswordSecurity, type PasswordSecurityReport } from '@/lib/password-security';
import type { Cipher } from '@/lib/types';
export interface PasswordSecurityState {
fingerprint: string;
report: PasswordSecurityReport | null;
scannedAt: number | null;
scanning: boolean;
progress: { checked: number; total: number };
scanError: boolean;
}
type InternalPasswordSecurityState = PasswordSecurityState & { controller: AbortController | null };
let state: InternalPasswordSecurityState | null = null;
const listeners = new Set<() => void>();
function notify(): void {
listeners.forEach((listener) => listener());
}
function createState(fingerprint: string): InternalPasswordSecurityState {
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
}
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
if (state?.fingerprint !== fingerprint) {
state?.controller?.abort();
state = createState(fingerprint);
}
return state;
}
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
return state?.fingerprint === fingerprint ? state : null;
}
export function subscribePasswordSecurityState(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
const current = getPasswordSecurityState(fingerprint);
current.controller?.abort();
const controller = new AbortController();
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
state = { ...current, report: null, scannedAt: null, scanning: true, progress: { checked: 0, total }, scanError: false, controller };
notify();
void (async () => {
try {
const report = await inspectVaultPasswordSecurity(ciphers, (checked, total) => {
if (controller.signal.aborted || state?.controller !== controller) return;
state = { ...state, progress: { checked, total } };
notify();
}, fetch, controller.signal);
if (controller.signal.aborted || state?.controller !== controller) return;
state = { ...state, report, scannedAt: Date.now() };
} catch (error) {
if (controller.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return;
if (state?.controller === controller) state = { ...state, scanError: true };
} finally {
if (state?.controller === controller) state = { ...state, controller: null, scanning: false };
notify();
}
})();
}
export function clearPasswordSecurityCache(): void {
state?.controller?.abort();
state = null;
notify();
}
+229
View File
@@ -0,0 +1,229 @@
import type { Cipher } from '@/lib/types';
const PWNED_PASSWORDS_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
const MAX_CONCURRENT_BREACH_CHECKS = 5;
const COMMON_PASSWORDS = new Set([
'password', 'password1', '123456', '12345678', '123456789', 'qwerty', 'abc123', 'letmein', 'welcome', 'iloveyou', 'admin', 'changeme',
]);
export interface PasswordBreachResult {
count: number | null;
available: boolean;
}
export interface PasswordSecurityItem {
cipherId: string;
exposedCount: number | null;
reusedCount: number;
weak: boolean;
}
export interface PasswordSecurityReport {
eligibleCount: number;
checkedCount: number;
exposedCount: number;
reusedCount: number;
weakCount: number;
unavailableCount: number;
items: PasswordSecurityItem[];
}
type Candidate = {
cipherId: string;
name: string;
hash: string;
weak: boolean;
};
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function isAbortError(error: unknown): boolean {
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
const error = new Error('The operation was aborted.');
error.name = 'AbortError';
throw error;
}
}
export async function sha1Password(password: string): Promise<string> {
const input = new TextEncoder().encode(password);
return bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-1', input)));
}
function parseRangeResponse(text: string, suffix: string): number {
for (const line of text.split(/\r?\n/)) {
const separator = line.indexOf(':');
if (separator !== 35) continue;
if (line.slice(0, separator).toUpperCase() !== suffix) continue;
const count = Number.parseInt(line.slice(separator + 1), 10);
return Number.isSafeInteger(count) && count > 0 ? count : 0;
}
return 0;
}
export async function checkPasswordHashLeaked(
hash: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<number> {
if (!/^[A-F0-9]{40}$/.test(hash)) throw new Error('Password hash is invalid.');
throwIfAborted(signal);
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 12_000);
const onExternalAbort = () => controller.abort();
signal?.addEventListener('abort', onExternalAbort, { once: true });
if (signal?.aborted) controller.abort();
try {
const response = await fetchImpl(`${PWNED_PASSWORDS_RANGE_URL}${hash.slice(0, 5)}`, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
cache: 'no-store',
referrerPolicy: 'no-referrer',
headers: { 'Add-Padding': 'true' },
signal: controller.signal,
});
if (!response.ok) throw new Error(`Pwned Passwords returned ${response.status}.`);
return parseRangeResponse(await response.text(), hash.slice(5));
} catch (error) {
// External cancel (leave page / re-scan) must stay distinguishable from timeout/network failures.
if (signal?.aborted) {
const abortError = new Error('The operation was aborted.');
abortError.name = 'AbortError';
throw abortError;
}
if (isAbortError(error)) throw new Error('Pwned Passwords request timed out.');
throw error;
} finally {
globalThis.clearTimeout(timeout);
signal?.removeEventListener('abort', onExternalAbort);
}
}
export async function checkPasswordLeaked(
password: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<PasswordBreachResult> {
if (!password) return { count: 0, available: true };
try {
return { count: await checkPasswordHashLeaked(await sha1Password(password), fetchImpl, signal), available: true };
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw error;
return { count: null, available: false };
}
}
function hasSimpleSequence(value: string): boolean {
const normalized = value.toLowerCase();
return ['0123456789', '9876543210', 'abcdefghijklmnopqrstuvwxyz', 'zyxwvutsrqponmlkjihgfedcba', 'qwertyuiop', 'poiuytrewq']
.some((sequence) => sequence.includes(normalized) || normalized.includes(sequence.slice(0, 5)));
}
export function isWeakPassword(password: string, username: string = ''): boolean {
const normalized = password.toLowerCase();
const compactUsername = username.split('@')[0]?.trim().toLowerCase() || '';
if (COMMON_PASSWORDS.has(normalized) || password.length < 10) return true;
if (/^(.)\1+$/.test(password) || hasSimpleSequence(password)) return true;
if (compactUsername.length >= 3 && normalized.includes(compactUsername)) return true;
const classes = [/[a-z]/.test(password), /[A-Z]/.test(password), /\d/.test(password), /[^A-Za-z0-9]/.test(password)].filter(Boolean).length;
return password.length < 14 && classes < 3;
}
function isEligibleCipher(cipher: Cipher): boolean {
return Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword;
}
async function mapWithConcurrency<T, R>(
values: T[],
limit: number,
worker: (value: T) => Promise<R>,
signal?: AbortSignal,
): Promise<R[]> {
const results = new Array<R>(values.length);
let nextIndex = 0;
const run = async () => {
while (true) {
throwIfAborted(signal);
const index = nextIndex;
nextIndex += 1;
if (index >= values.length) return;
results[index] = await worker(values[index]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run));
return results;
}
export async function inspectVaultPasswordSecurity(
ciphers: Cipher[],
onProgress?: (checked: number, total: number) => void,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<PasswordSecurityReport> {
throwIfAborted(signal);
const eligible = ciphers.filter(isEligibleCipher);
const candidates: Candidate[] = await Promise.all(eligible.map(async (cipher) => {
throwIfAborted(signal);
const password = String(cipher.login?.decPassword || '');
const username = String(cipher.login?.decUsername || '');
return {
cipherId: cipher.id,
name: String(cipher.decName || cipher.name || ''),
hash: await sha1Password(password),
weak: isWeakPassword(password, username),
};
}));
const candidatesByHash = new Map<string, Candidate[]>();
for (const candidate of candidates) {
const group = candidatesByHash.get(candidate.hash) || [];
group.push(candidate);
candidatesByHash.set(candidate.hash, group);
}
const exposureByHash = new Map<string, PasswordBreachResult>();
let checked = 0;
await mapWithConcurrency([...candidatesByHash.keys()], MAX_CONCURRENT_BREACH_CHECKS, async (hash) => {
throwIfAborted(signal);
let result: PasswordBreachResult;
try {
result = { count: await checkPasswordHashLeaked(hash, fetchImpl, signal), available: true };
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw error;
result = { count: null, available: false };
}
exposureByHash.set(hash, result);
checked += candidatesByHash.get(hash)?.length || 0;
onProgress?.(Math.min(checked, candidates.length), candidates.length);
return result;
}, signal);
throwIfAborted(signal);
const items = candidates.map((candidate) => {
const exposure = exposureByHash.get(candidate.hash) || { count: null, available: false };
return {
cipherId: candidate.cipherId,
exposedCount: exposure.count,
reusedCount: candidatesByHash.get(candidate.hash)?.length || 1,
weak: candidate.weak,
};
}).filter((item) => item.exposedCount === null || (item.exposedCount || 0) > 0 || item.reusedCount > 1 || item.weak)
.sort((a, b) => (Number(b.exposedCount || 0) - Number(a.exposedCount || 0)) || (b.reusedCount - a.reusedCount) || Number(b.weak) - Number(a.weak) || a.cipherId.localeCompare(b.cipherId));
return {
eligibleCount: candidates.length,
checkedCount: checked,
exposedCount: candidates.filter((candidate) => (exposureByHash.get(candidate.hash)?.count || 0) > 0).length,
reusedCount: candidates.filter((candidate) => (candidatesByHash.get(candidate.hash)?.length || 0) > 1).length,
weakCount: candidates.filter((candidate) => candidate.weak).length,
unavailableCount: candidates.filter((candidate) => exposureByHash.get(candidate.hash)?.count === null).length,
items,
};
}
+4 -2
View File
@@ -2,6 +2,8 @@
@import './styles/base.css'; @import './styles/base.css';
@import './styles/auth.css'; @import './styles/auth.css';
@import './styles/forms.css'; @import './styles/forms.css';
@import './styles/generator.css';
@import './styles/password-security.css';
@import './styles/shell.css'; @import './styles/shell.css';
@import './styles/vault.css'; @import './styles/vault.css';
@import './styles/management.css'; @import './styles/management.css';
@@ -428,7 +430,7 @@ h4 {
min-height: min(640px, calc(100dvh - 180px)); min-height: min(640px, calc(100dvh - 180px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 10px;
} }
.settings-home-section { .settings-home-section {
@@ -675,7 +677,7 @@ h4 {
} }
.card { .card {
margin-bottom: 8px; margin-bottom: 0px;
padding: 14px; padding: 14px;
} }
+52
View File
@@ -492,6 +492,58 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.offline-mode-notice {
@apply mb-4 flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left text-sm leading-relaxed;
background: color-mix(in srgb, var(--warning) 10%, var(--panel));
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
color: var(--text);
}
.offline-mode-notice svg {
@apply mt-0.5 shrink-0;
color: var(--warning);
}
.offline-mode-notice strong {
@apply block text-center text-[13px] font-extrabold;
color: color-mix(in srgb, var(--warning) 82%, var(--text));
}
.offline-mode-notice > div {
@apply min-w-0 flex-1;
}
.offline-shortcut-list {
@apply mt-2 grid gap-1.5;
grid-template-columns: max-content minmax(0, 1fr);
}
.offline-shortcut-row {
display: contents;
}
.offline-shortcut-label {
@apply text-xs font-extrabold;
color: color-mix(in srgb, var(--warning) 76%, var(--text));
}
.offline-shortcut-value {
@apply flex min-w-0 flex-wrap items-center gap-1.5;
color: var(--muted-strong);
}
.offline-shortcut-chord {
@apply inline-flex items-center gap-1 whitespace-nowrap;
}
.offline-shortcut-chord kbd {
@apply inline-flex min-h-6 items-center rounded-md border px-1.5 font-mono text-[12px] font-bold leading-none;
background: color-mix(in srgb, var(--panel) 88%, var(--warning));
border-color: color-mix(in srgb, var(--warning) 35%, var(--line));
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--warning) 26%, transparent);
color: var(--text);
}
.standalone-muted { .standalone-muted {
@apply text-left; @apply text-left;
} }
+25
View File
@@ -0,0 +1,25 @@
.generator-page {
width: min(100%, 1180px);
margin: 0;
padding: 4px 0 28px;
}
.generator-layout { display: grid; grid-template-columns: minmax(300px, .82fr) minmax(0, 1.18fr); grid-template-areas: 'options output'; gap: 16px; align-items: start; }
.generator-output-card, .generator-options-card { border: 1px solid var(--line); border-radius: 20px; background: var(--panel); box-shadow: var(--shadow-sm); }
.generator-output-card { grid-area: output; padding: 20px; }
.generator-options-card { grid-area: options; padding: 19px; }
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; }
.generator-value { display: block; min-height: 110px; margin: 18px 0 10px; padding: 18px; border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--primary) 5%, var(--panel)); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(19px, 2.2vw, 27px); font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; user-select: all; }
.generator-strength-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
.generator-strength-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; }
.generator-strength { display: flex; flex: 1; gap: 4px; }
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); }
.generator-actions { margin-top: 22px; }.generator-actions .btn { flex: 1; }
.generator-security-note { margin: 18px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }.generator-security-note svg { color: var(--success); flex: 0 0 auto; }
.generator-number-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 15px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-number-field > label { min-width: 0; }.generator-stepper { display: grid; grid-template-columns: 38px 64px 38px; align-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.generator-stepper button { display: grid; width: 38px; height: 40px; place-items: center; border: 0; background: transparent; color: var(--primary-strong); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }.generator-stepper button:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 10%, var(--panel)); }.generator-stepper button:active:not(:disabled) { background: color-mix(in srgb, var(--primary) 17%, var(--panel)); }.generator-stepper button:focus-visible { position: relative; z-index: 1; outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: -3px; }.generator-stepper button:disabled { color: var(--muted); cursor: not-allowed; }.generator-stepper .input { width: 64px; height: 40px; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; text-align: center; font-variant-numeric: tabular-nums; }.generator-stepper .input:focus { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary) 36%, transparent); }.generator-number-field.compact { grid-template-columns: minmax(0, 1fr) auto; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 32px 46px 32px; border-radius: 9px; }.generator-number-field.compact .generator-stepper button { width: 32px; height: 34px; }.generator-number-field.compact .generator-stepper .input { width: 46px; height: 34px; font-size: 13px; }
.generator-option-group { display: grid; gap: 9px; margin: 18px 0; padding: 0; border: 0; }.generator-option-group legend { margin-bottom: 10px; padding: 0; font-size: 14px; font-weight: 700; }
.generator-toggle { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }.generator-toggle input { position: absolute; opacity: 0; }.generator-toggle > span { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #cbd5e1; transition: background 180ms ease; }.generator-toggle > span::after { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ''; transition: transform 180ms ease; }.generator-toggle input:checked + span { background: var(--primary); }.generator-toggle input:checked + span::after { transform: translateX(16px); }.generator-toggle input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }.generator-toggle strong { font-size: 14px; font-weight: 600; }
.generator-inline-number { display: grid; grid-template-columns: minmax(0, 1fr) 72px; align-items: center; gap: 14px; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-inline-number .input { height: 34px; text-align: center; }
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; } }
+77
View File
@@ -0,0 +1,77 @@
.password-security-page { width: min(100%, 1180px); margin: 0; display: grid; gap: 10px; padding: 4px 0 24px; }
.password-security-intro { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
.password-security-intro-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 14px; color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
.password-security-intro h2 { margin: 0 0 3px; font-size: 18px; }
.password-security-intro p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
.password-security-intro .password-security-checked-at { margin-top: 4px; font-size: 12px; font-variant-numeric: tabular-nums; }
.password-security-intro-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.password-security-scan, .password-security-toggle-all { min-height: 40px; }
.password-security-empty { min-height: 190px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: var(--muted); padding: 28px; }
.password-security-empty > svg { color: var(--primary); }
.password-security-empty strong { color: var(--ink); }
.password-security-empty span { font-size: 14px; max-width: 520px; line-height: 1.5; }
.password-security-empty.compact { min-height: 150px; }
.password-security-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.password-security-metric { display: flex; align-items: center; gap: 10px; min-height: 72px; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); box-shadow: var(--shadow-sm); color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
.password-security-metric:hover:not(:disabled), .password-security-metric[aria-pressed='true'] { border-color: var(--primary); box-shadow: var(--shadow-md); }
.password-security-metric:active:not(:disabled) { transform: scale(.99); }
.password-security-metric:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 45%, transparent); outline-offset: 2px; }
.password-security-metric:disabled { cursor: default; }
.password-security-metric > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; }
.password-security-metric.danger > span { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, var(--panel)); }
.password-security-metric.warning > span { color: #b45309; background: #fff7e6; }
.password-security-metric.primary > span { color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
.password-security-metric div { display: grid; gap: 1px; min-width: 0; }
.password-security-metric strong { font-size: 20px; line-height: 1.15; font-variant-numeric: tabular-nums; }
.password-security-metric small { color: var(--muted); font-size: 12px; }
.password-security-results { padding: 8px; }
.password-security-notice { display: flex; align-items: center; gap: 8px; padding: 9px 10px; margin-bottom: 8px; border-radius: var(--radius-md); font-size: 13px; }
.password-security-notice.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
.password-security-list { display: grid; }
.password-security-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 64px; padding: 10px; border-bottom: 1px solid var(--line-soft); }
.password-security-item:last-child { border-bottom: 0; }
.password-security-item-main { min-width: 0; display: grid; gap: 5px; }
.password-security-item-header { display: flex; align-items: center; gap: 8px; min-width: 0; }
.password-security-item-header > strong { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.password-security-password { min-width: 0; color: var(--muted); font-size: 13px; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.password-security-item-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; }
.password-security-badges { display: flex; flex-wrap: wrap; gap: 6px; }
.risk-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 7px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.risk-badge.danger { color: #b42318; background: #fef0ef; }
.risk-badge.reused { color: #92400e; background: #fff7e6; }
.risk-badge.weak { color: #5b21b6; background: #f3e8ff; }
.risk-badge.muted { color: var(--muted); background: var(--panel-soft); }
.password-security-open { flex: 0 0 auto; }
.password-breach-inline { display: flex; align-items: center; gap: 7px; margin-top: 9px; padding: 9px 10px; border-radius: var(--radius-md); font-size: 13px; line-height: 1.35; }
.password-breach-inline.safe { color: #16704d; background: #ecfdf3; border: 1px solid #b7ebcd; }
.password-breach-inline.danger { color: #b42318; background: #fef0ef; border: 1px solid #fecdc9; }
.password-breach-inline.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
.spin { animation: password-security-spin 900ms linear infinite; }
@keyframes password-security-spin { to { transform: rotate(360deg); } }
@media (max-width: 760px) {
.password-security-page { width: 100%; padding: 0 0 18px; gap: 10px; }
.password-security-intro { align-items: flex-start; padding: 14px; }
.password-security-intro-icon { width: 38px; height: 38px; border-radius: 12px; }
.password-security-intro h2 { font-size: 16px; }
.password-security-intro p { font-size: 13px; }
.password-security-intro-actions { width: 100%; margin: 8px 0 0; grid-column: 1 / -1; }
.password-security-scan, .password-security-toggle-all { flex: 1 1 0; }
.password-security-intro { display: grid; grid-template-columns: auto minmax(0, 1fr); }
.password-security-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.password-security-metric { min-height: 74px; padding: 12px; gap: 8px; }
.password-security-metric > span { width: 32px; height: 32px; border-radius: 10px; }
.password-security-metric strong { font-size: 18px; }
.password-security-item { align-items: stretch; flex-direction: column; gap: 8px; }
.password-security-item-actions { width: 100%; }
.password-security-item-actions > * { flex: 1 1 0; min-height: 40px; }
}
:root[data-theme='dark'] .password-security-metric.warning > span,
:root[data-theme='dark'] .password-security-notice.warning,
:root[data-theme='dark'] .risk-badge.reused,
:root[data-theme='dark'] .password-breach-inline.warning { color: #fbbf24; background: rgba(180, 83, 9, .18); border-color: rgba(251, 191, 36, .25); }
:root[data-theme='dark'] .risk-badge.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); }
:root[data-theme='dark'] .risk-badge.weak { color: #d8b4fe; background: rgba(91, 33, 182, .22); }
:root[data-theme='dark'] .password-breach-inline.safe { color: #6ee7b7; background: rgba(22, 112, 77, .2); border-color: rgba(110, 231, 183, .25); }
:root[data-theme='dark'] .password-breach-inline.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); border-color: rgba(252, 165, 165, .25); }
+1 -1
View File
@@ -234,7 +234,7 @@
.mobile-tabbar { .mobile-tabbar {
@apply grid items-center gap-1.5; @apply grid items-center gap-1.5;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
min-height: var(--mobile-tabbar-height); min-height: var(--mobile-tabbar-height);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line); border-top: 1px solid var(--line);