mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
9
Commits
0e46cd371f
...
v1.7.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b093c01fd7 | ||
|
|
fa611dc843 | ||
|
|
3c581d1fb1 | ||
|
|
fb376797d2 | ||
|
|
99b50275a6 | ||
|
|
dfc98008cb | ||
|
|
b472121f43 | ||
|
|
9caa064488 | ||
|
|
aae614a079 |
@@ -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
|
||||||
|
|
||||||
[](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
@@ -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
|
||||||
|
|
||||||
[](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>
|
||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
export const APP_VERSION = '1.7.3';
|
export const APP_VERSION = '1.7.4';
|
||||||
|
|||||||
+47
-14
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_create_timed_invite'),
|
||||||
|
message: t('txt_enter_master_password_to_continue'),
|
||||||
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await createInvite(authedFetch, hours);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await createInvite(authedFetch, hours, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_invite_created'));
|
onNotify('success', t('txt_invite_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
|
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') {
|
||||||
|
const nextStatus = status === 'active' ? 'banned' : 'active';
|
||||||
|
onSetConfirm({
|
||||||
|
title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
|
||||||
|
message: t('txt_enter_master_password_to_continue'),
|
||||||
|
danger: nextStatus === 'banned',
|
||||||
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await setUserStatus(authedFetch, userId, nextStatus, hash);
|
||||||
await refetchUsers();
|
await refetchUsers();
|
||||||
onNotify('success', t('txt_user_status_updated'));
|
onNotify('success', t('txt_user_status_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
|
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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-12
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; } }
|
||||||
@@ -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); }
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user