mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-11-04 18:40:20 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
588dce6a0a | ||
|
|
589557a757 | ||
|
|
99d404f7cb | ||
|
|
a72592e76e | ||
|
|
e63f9663e8 | ||
|
|
df6b0b9767 | ||
|
|
64037695b8 | ||
|
|
d43f21e8e0 | ||
|
|
e4368e6129 | ||
|
|
00433a7cda | ||
|
|
13bb0a0308 | ||
|
|
b3630bdaf5 | ||
|
|
38b0ff6263 | ||
|
|
ecc0d134ac | ||
|
|
f644baaf8d | ||
|
|
fb627f59f0 | ||
|
|
652952379b | ||
|
|
34fd2f0259 | ||
|
|
df493b4697 | ||
|
|
f5cc02b93f |
No files matched your search
@@ -44,6 +44,7 @@ tmp/
|
||||
.tmp-bitwarden-clients/
|
||||
|
||||
nodewarden-wiki/
|
||||
nodewarden.wiki/
|
||||
wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
@@ -57,6 +58,7 @@ NodeWarden-compat/
|
||||
|
||||
.reasonix/
|
||||
.upstream/
|
||||
bitwarden-upstream/
|
||||
|
||||
# Compatibility analysis documents
|
||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||
|
||||
@@ -91,6 +91,25 @@
|
||||
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
|
||||
## FAQ
|
||||
|
||||
- **After forking the repository, why can't I see my repository when connecting GitHub to Cloudflare, or why do I get a 404 after selecting it?**
|
||||
This is usually related to how the GitHub fork is identified or how Cloudflare handles repository authorization and synchronization. If the fork keeps a repository name, description, or other information that is very similar to the upstream project, it may be more likely to trigger related restrictions or issues. It is recommended to rename the repository to something different from the upstream project when creating the fork, and change the repository description as well. For example, you can rename it to `2233warden`. If you have already created the fork, you can rename the repository and update its description in the GitHub repository settings, then try connecting it to Cloudflare again.
|
||||
|
||||
- **I deleted my deployment and redeployed it. Why does registration require an invite code again?**
|
||||
Deleting the Worker or redeploying it does not automatically delete the persistent data that was already created. The users, invite codes, and related configuration stored in the D1 database and KV namespace are still there, so the newly deployed Worker continues to read the existing data and enforce the invite-code requirement.
|
||||
If you want to start completely from scratch, you need to delete the corresponding **D1 database and KV namespace** as well.
|
||||
|
||||
- **I configured `JWT_SECRET`, but the page still says it is missing. Why?**
|
||||
Make sure `JWT_SECRET` is configured under **Workers → Settings → Variables and Secrets**, specifically as a **Runtime variable or Secret**, rather than under **Build variables**.
|
||||
Build-time variables are only available during the build process. They are not available to the Worker at runtime, so the build may succeed while the application still reports that `JWT_SECRET` is missing.
|
||||
|
||||
- **Why does `JWT_SECRET` seem to disappear after an upgrade or redeployment?**
|
||||
It is recommended to store `JWT_SECRET` as a **Secret** rather than as a plain-text variable. `JWT_SECRET` is a sensitive runtime credential and should not be committed to the repository.
|
||||
If your deployment process recreates or overwrites the Worker variable configuration, ordinary variables may be affected. Secrets are more appropriate for sensitive configuration that needs to remain available across multiple deployments. If the application still reports that `JWT_SECRET` is missing after a redeployment, check **Variables and Secrets** for the current Worker and make sure the Secret is still configured.
|
||||
|
||||
---
|
||||
|
||||
## How to update
|
||||
|
||||
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||
|
||||
@@ -91,6 +91,24 @@
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
|
||||
|
||||
## 常见问题:
|
||||
- **Fork 完仓库后,在 Cloudflare 连接 GitHub 账户时看不到自己的仓库,或者选择仓库后返回 404?**
|
||||
这通常与 GitHub Fork 仓库的识别或 Cloudflare 对仓库的授权/同步有关。如果 Fork 后仓库名称、描述等信息与上游项目高度一致,可能更容易触发相关限制或异常。建议在 Fork 时就将仓库名称修改为与上游不同的名称,并同时修改仓库描述,例如改为 `2233warden`。如果已经完成 Fork,也可以直接在 GitHub 仓库设置中修改名称和描述,然后重新尝试在 Cloudflare 中连接。
|
||||
|
||||
- **我删掉部署后重新部署,为什么注册又开始要求邀请码了?**
|
||||
因为删除 Worker 或重新部署并不会自动删除已经创建的持久化数据。D1 数据库和 KV 命名空间中的用户、邀请码及相关配置仍然存在,因此重新部署后仍会读取原来的数据,并继续要求邀请码。
|
||||
如果希望完全重新开始,需要同时删除对应的 **D1 数据库和 KV 命名空间**。
|
||||
|
||||
- **我配置了 `JWT_SECRET`,为什么页面仍然提示缺少?**
|
||||
请将 `JWT_SECRET` 配置在 Cloudflare Workers 的 **Settings → Variables and Secrets** 中,并确保它属于 **Runtime variables and secrets**,而不是 **Build variables**。
|
||||
Build 阶段的变量只在项目构建过程中可用,Worker 实际运行时无法通过运行时环境读取,因此即使构建能够正常完成,页面仍可能提示 `JWT_SECRET` 缺失。
|
||||
|
||||
- **为什么升级或重新部署后,`JWT_SECRET` 好像消失了?**
|
||||
建议将 `JWT_SECRET` 配置为 **Secret**,而不是普通的明文变量。`JWT_SECRET` 属于敏感的运行时凭据,也不应该出现在代码仓库中。
|
||||
如果部署流程会重新生成或覆盖 Worker 的变量配置,普通变量可能受到影响;使用 Secret 更适合保存这类需要在多次部署之间持续存在的敏感配置。重新部署后如果仍提示缺失,请检查当前 Worker 的 **Variables and Secrets** 中是否仍存在该 Secret。
|
||||
|
||||
---
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
|
||||
|
||||
Generated
+172
-551
File diff suppressed because it is too large.
Load diff
+8
-3
@@ -17,6 +17,7 @@
|
||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
|
||||
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
|
||||
"test:notifications-security": "tsx --test scripts/notifications-security.test.ts",
|
||||
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
|
||||
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||
@@ -48,11 +49,15 @@
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"undici": ">=7.28.0",
|
||||
"nanoid": "3.3.18",
|
||||
"undici": "8.9.0",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"esbuild": ">=0.28.1",
|
||||
"ws": "8.21.0",
|
||||
"sharp": "0.35.0"
|
||||
"sharp": "0.35.4",
|
||||
"browserslist": "4.28.7",
|
||||
"baseline-browser-mapping": "2.11.0",
|
||||
"postcss-selector-parser": "6.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
@@ -63,7 +68,7 @@
|
||||
"postcss": "^8.5.23",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleNotificationsHub, handleNotificationsNegotiate } from '../src/handlers/notifications';
|
||||
import type { Env } from '../src/types';
|
||||
import { createJWT } from '../src/utils/jwt';
|
||||
|
||||
const secret = 'notification-security-test-secret-32-bytes';
|
||||
const userId = 'd75020e1-2de4-46e8-b8f1-475d127b51f2';
|
||||
const securityStamp = 'security-stamp';
|
||||
|
||||
function createTestEnv() {
|
||||
const connectionTokens = new Map<string, { userId: string; deviceIdentifier: string | null; expiresAt: number }>();
|
||||
const forwardedHubUrls: string[] = [];
|
||||
const durableObjectNames: string[] = [];
|
||||
const userRow = {
|
||||
id: userId,
|
||||
email: 'user@example.test',
|
||||
name: 'Test User',
|
||||
master_password_hint: null,
|
||||
master_password_hash: 'hash',
|
||||
key: 'key',
|
||||
private_key: null,
|
||||
public_key: null,
|
||||
kdf_type: 0,
|
||||
kdf_iterations: 600000,
|
||||
kdf_memory: null,
|
||||
kdf_parallelism: null,
|
||||
security_stamp: securityStamp,
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
verify_devices: 0,
|
||||
totp_secret: null,
|
||||
totp_recovery_code: null,
|
||||
yubikey_key1: null,
|
||||
yubikey_key2: null,
|
||||
yubikey_key3: null,
|
||||
yubikey_key4: null,
|
||||
yubikey_key5: null,
|
||||
yubikey_nfc: 0,
|
||||
api_key: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const db = {
|
||||
prepare() {
|
||||
return {
|
||||
bind() {
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return userRow;
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
|
||||
const stub = {
|
||||
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const request = new Request(input, init);
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/internal/ws-token') {
|
||||
const body = await request.json() as {
|
||||
token: string;
|
||||
userId: string;
|
||||
deviceIdentifier: string | null;
|
||||
expiresAt: number;
|
||||
};
|
||||
connectionTokens.set(body.token, body);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.pathname === '/internal/ws-token/consume') {
|
||||
const { token } = await request.json() as { token: string };
|
||||
const connection = connectionTokens.get(token);
|
||||
connectionTokens.delete(token);
|
||||
if (!connection || connection.expiresAt <= Date.now()) return new Response(null, { status: 401 });
|
||||
return Response.json(connection);
|
||||
}
|
||||
forwardedHubUrls.push(request.url);
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
};
|
||||
|
||||
const env = {
|
||||
DB: db,
|
||||
JWT_SECRET: secret,
|
||||
NOTIFICATIONS_HUB: {
|
||||
idFromName(name: string) {
|
||||
durableObjectNames.push(name);
|
||||
return name;
|
||||
},
|
||||
get() {
|
||||
return stub;
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
|
||||
return { env, connectionTokens, durableObjectNames, forwardedHubUrls };
|
||||
}
|
||||
|
||||
async function validAccessToken(): Promise<string> {
|
||||
return createJWT({
|
||||
sub: userId,
|
||||
email: 'user@example.test',
|
||||
name: 'Test User',
|
||||
sstamp: securityStamp,
|
||||
}, secret);
|
||||
}
|
||||
|
||||
test('query access_token cannot authenticate a websocket', async () => {
|
||||
const { env, forwardedHubUrls } = createTestEnv();
|
||||
const token = await validAccessToken();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
`https://vault.example.test/notifications/hub?access_token=${encodeURIComponent(token)}`,
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(forwardedHubUrls, []);
|
||||
});
|
||||
|
||||
test('Authorization bearer token still authenticates notifications', async () => {
|
||||
const { env, forwardedHubUrls } = createTestEnv();
|
||||
const token = await validAccessToken();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
'https://vault.example.test/notifications/hub',
|
||||
{ headers: { Authorization: `Bearer ${token}`, Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(new URL(forwardedHubUrls[0]).searchParams.get('nw_uid'), userId);
|
||||
});
|
||||
|
||||
test('negotiate issues a short-lived one-time websocket connection token', async () => {
|
||||
const { env, connectionTokens, forwardedHubUrls } = createTestEnv();
|
||||
const accessToken = await validAccessToken();
|
||||
const negotiate = await handleNotificationsNegotiate(new Request(
|
||||
'https://vault.example.test/notifications/hub/negotiate',
|
||||
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
), env);
|
||||
const body = await negotiate.json() as { connectionToken: string };
|
||||
const stored = connectionTokens.get(body.connectionToken);
|
||||
|
||||
assert.equal(negotiate.status, 200);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.expiresAt > Date.now());
|
||||
assert.ok(stored.expiresAt <= Date.now() + 60_000);
|
||||
|
||||
const request = () => new Request(
|
||||
`https://vault.example.test/notifications/hub?id=${encodeURIComponent(body.connectionToken)}`,
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
);
|
||||
assert.equal((await handleNotificationsHub(request(), env)).status, 204);
|
||||
assert.equal((await handleNotificationsHub(request(), env)).status, 401);
|
||||
assert.equal(forwardedHubUrls.length, 1);
|
||||
});
|
||||
|
||||
test('a non-upgrade request does not consume a websocket connection token', async () => {
|
||||
const { env, connectionTokens } = createTestEnv();
|
||||
const accessToken = await validAccessToken();
|
||||
const negotiate = await handleNotificationsNegotiate(new Request(
|
||||
'https://vault.example.test/notifications/hub/negotiate',
|
||||
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
), env);
|
||||
const { connectionToken } = await negotiate.json() as { connectionToken: string };
|
||||
const url = `https://vault.example.test/notifications/hub?id=${encodeURIComponent(connectionToken)}`;
|
||||
|
||||
assert.equal((await handleNotificationsHub(new Request(url), env)).status, 426);
|
||||
assert.ok(connectionTokens.has(connectionToken));
|
||||
assert.equal((await handleNotificationsHub(new Request(url, { headers: { Upgrade: 'websocket' } }), env)).status, 204);
|
||||
});
|
||||
|
||||
test('a forged ticket cannot select or activate a Durable Object', async () => {
|
||||
const { env, durableObjectNames } = createTestEnv();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
'https://vault.example.test/notifications/hub?id=attacker-controlled.invalid-signature',
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(durableObjectNames, []);
|
||||
});
|
||||
@@ -19,6 +19,8 @@ const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||
const WEBSOCKET_CONNECTION_TOKEN_PREFIX = 'ws-token:';
|
||||
const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000;
|
||||
|
||||
type HubProtocol = 'json' | 'messagepack';
|
||||
type HubKind = 'user' | 'anonymous-auth-request';
|
||||
@@ -32,6 +34,12 @@ interface WsAttachment {
|
||||
deviceIdentifier: string | null;
|
||||
}
|
||||
|
||||
interface WebSocketConnectionToken {
|
||||
userId: string;
|
||||
deviceIdentifier: string | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
@@ -207,6 +215,52 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/internal/ws-token' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
token?: string;
|
||||
userId?: string;
|
||||
deviceIdentifier?: string | null;
|
||||
expiresAt?: number;
|
||||
} | null;
|
||||
const token = String(body?.token || '').trim();
|
||||
const userId = String(body?.userId || '').trim();
|
||||
const expiresAt = Number(body?.expiresAt || 0);
|
||||
if (!token || !userId || expiresAt <= Date.now() || expiresAt > Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS) {
|
||||
return new Response('Invalid websocket connection token', { status: 400 });
|
||||
}
|
||||
await this.ctx.storage.put(`${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`, {
|
||||
userId,
|
||||
deviceIdentifier: String(body?.deviceIdentifier || '').trim() || null,
|
||||
expiresAt,
|
||||
} satisfies WebSocketConnectionToken);
|
||||
const currentAlarm = await this.ctx.storage.getAlarm();
|
||||
if (currentAlarm === null || expiresAt < currentAlarm) {
|
||||
await this.ctx.storage.setAlarm(expiresAt);
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/ws-token/consume' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as { token?: string } | null;
|
||||
const token = String(body?.token || '').trim();
|
||||
if (!token) return new Response('Invalid websocket connection token', { status: 400 });
|
||||
|
||||
// Delete inside a transaction so a connection ticket cannot win two concurrent upgrades.
|
||||
const connection = await this.ctx.storage.transaction(async (txn) => {
|
||||
const key = `${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`;
|
||||
const stored = await txn.get<WebSocketConnectionToken>(key);
|
||||
if (stored) await txn.delete(key);
|
||||
return stored || null;
|
||||
});
|
||||
if (!connection || connection.expiresAt <= Date.now()) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
return new Response(JSON.stringify(connection), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/notify' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
revisionDate?: string;
|
||||
@@ -301,6 +355,27 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
});
|
||||
}
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const tokens = await this.ctx.storage.list<WebSocketConnectionToken>({
|
||||
prefix: WEBSOCKET_CONNECTION_TOKEN_PREFIX,
|
||||
});
|
||||
const expiredKeys: string[] = [];
|
||||
let nextExpiration: number | null = null;
|
||||
|
||||
for (const [key, token] of tokens) {
|
||||
if (token.expiresAt <= now) {
|
||||
expiredKeys.push(key);
|
||||
} else if (nextExpiration === null || token.expiresAt < nextExpiration) {
|
||||
nextExpiration = token.expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Negotiated tickets that never reach an upgrade must not remain in DO storage indefinitely.
|
||||
if (expiredKeys.length > 0) await this.ctx.storage.delete(expiredKeys);
|
||||
if (nextExpiration !== null) await this.ctx.storage.setAlarm(nextExpiration);
|
||||
}
|
||||
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment) return;
|
||||
|
||||
+8
-13
@@ -1247,19 +1247,14 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
|
||||
if (request.method === 'POST') {
|
||||
try {
|
||||
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||
} catch {
|
||||
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||
}
|
||||
} else {
|
||||
input = {
|
||||
blobName: url.searchParams.get('blobName') || '',
|
||||
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
|
||||
};
|
||||
// Read the request body only. Accepting these fields from the query string
|
||||
// would put the master-password authentication hash in the URL, where it is
|
||||
// captured by request logs, browser history and Referer headers.
|
||||
let input: { blobName?: unknown; masterPasswordHash?: unknown };
|
||||
try {
|
||||
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||
} catch {
|
||||
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
interface AliasedValue<T> {
|
||||
present: boolean;
|
||||
value: T | null | undefined;
|
||||
}
|
||||
|
||||
function readOwnAliasedValue<T>(source: unknown, aliases: readonly string[]): AliasedValue<T> {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
const record = source as Record<string, unknown>;
|
||||
for (const alias of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(record, alias)) {
|
||||
return { present: true, value: record[alias] as T | null | undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Full cipher updates use replacement semantics for nullable fields.
|
||||
* Bitwarden clients may omit a property after its value is cleared, so an
|
||||
* absent property must become null instead of falling back to stored data.
|
||||
*/
|
||||
export function readNullableFullUpdateField<T>(
|
||||
source: unknown,
|
||||
aliases: readonly string[]
|
||||
): T | null {
|
||||
const incoming = readOwnAliasedValue<T>(source, aliases);
|
||||
return incoming.present ? incoming.value ?? null : null;
|
||||
}
|
||||
+5
-10
@@ -27,6 +27,7 @@ import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from '.
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { readNullableFullUpdateField } from './cipher-full-update';
|
||||
|
||||
// CONTRACT:
|
||||
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
|
||||
@@ -1100,16 +1101,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||
}
|
||||
|
||||
// Custom fields deletion compatibility:
|
||||
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
|
||||
// This prevents stale custom fields from being resurrected by merge fallback.
|
||||
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
if (incomingFields.present) {
|
||||
cipher.fields = incomingFields.value ?? null;
|
||||
} else if (request.method === 'PUT' || request.method === 'POST') {
|
||||
cipher.fields = null;
|
||||
}
|
||||
// Nullable fields use replacement semantics on this full-update endpoint.
|
||||
// Some clients omit cleared values, so merge fallback must not resurrect them.
|
||||
cipher.notes = readNullableFullUpdateField<string>(cipherData, ['notes', 'Notes']);
|
||||
cipher.fields = readNullableFullUpdateField<Cipher['fields']>(cipherData, ['fields', 'Fields']);
|
||||
normalizeCipherForStorage(cipher);
|
||||
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||
if (compatibilityError) return errorResponse(compatibilityError, 400);
|
||||
|
||||
@@ -4,18 +4,20 @@ import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import type { Env, JWTPayload } from '../types';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import {
|
||||
createWebSocketConnectionToken,
|
||||
verifyWebSocketConnectionToken,
|
||||
} from '../utils/websocket-connection-token';
|
||||
|
||||
const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000;
|
||||
|
||||
function extractAccessToken(request: Request): string | null {
|
||||
const url = new URL(request.url);
|
||||
const queryToken = String(url.searchParams.get('access_token') || '').trim();
|
||||
if (queryToken) return queryToken;
|
||||
|
||||
const authHeader = String(request.headers.get('Authorization') || '').trim();
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
async function authenticateAccessToken(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
const accessToken = extractAccessToken(request);
|
||||
if (!accessToken) return null;
|
||||
|
||||
@@ -23,30 +25,90 @@ async function authenticateNotificationsRequest(request: Request, env: Env): Pro
|
||||
return auth.verifyAccessToken(`Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
async function issueWebSocketConnectionToken(payload: JWTPayload, env: Env): Promise<string> {
|
||||
const expiresAt = Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS;
|
||||
const token = await createWebSocketConnectionToken(payload.sub, expiresAt, env.JWT_SECRET);
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(payload.sub);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
const response = await stub.fetch('https://notifications/internal/ws-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
userId: payload.sub,
|
||||
deviceIdentifier: payload.did || null,
|
||||
expiresAt,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to issue websocket connection token');
|
||||
return token;
|
||||
}
|
||||
|
||||
async function consumeWebSocketConnectionToken(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
const token = String(new URL(request.url).searchParams.get('id') || '').trim();
|
||||
const claims = await verifyWebSocketConnectionToken(token, env.JWT_SECRET);
|
||||
if (!claims) return null;
|
||||
|
||||
// Verify the signed routing claim before selecting a Durable Object. Otherwise an
|
||||
// attacker could activate arbitrary object names with forged token prefixes.
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(claims.userId);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
const response = await stub.fetch('https://notifications/internal/ws-token/consume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const connection = (await response.json().catch(() => null)) as {
|
||||
userId?: string;
|
||||
deviceIdentifier?: string | null;
|
||||
} | null;
|
||||
if (connection?.userId !== claims.userId) return null;
|
||||
|
||||
return {
|
||||
sub: claims.userId,
|
||||
did: String(connection.deviceIdentifier || '').trim() || undefined,
|
||||
} as JWTPayload;
|
||||
}
|
||||
|
||||
async function authenticateNotificationsHub(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
// Never accept an access JWT from the URL: URLs are routinely retained by logs,
|
||||
// browser history, proxies, monitoring, and error tracking systems.
|
||||
if (request.headers.has('Authorization')) {
|
||||
return authenticateAccessToken(request, env);
|
||||
}
|
||||
return consumeWebSocketConnectionToken(request, env);
|
||||
}
|
||||
|
||||
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
|
||||
const payload = await authenticateNotificationsRequest(request, env);
|
||||
const payload = await authenticateAccessToken(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
|
||||
const connectionId = generateUUID();
|
||||
return jsonResponse({
|
||||
connectionId,
|
||||
connectionToken: connectionId,
|
||||
negotiateVersion: 1,
|
||||
availableTransports: [
|
||||
{
|
||||
transport: 'WebSockets',
|
||||
transferFormats: ['Text', 'Binary'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const connectionToken = await issueWebSocketConnectionToken(payload, env);
|
||||
return jsonResponse(
|
||||
{
|
||||
connectionId: generateUUID(),
|
||||
connectionToken,
|
||||
negotiateVersion: 1,
|
||||
availableTransports: [
|
||||
{
|
||||
transport: 'WebSockets',
|
||||
transferFormats: ['Text', 'Binary'],
|
||||
},
|
||||
],
|
||||
},
|
||||
200,
|
||||
{ 'Cache-Control': 'no-store' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||
const payload = await authenticateNotificationsRequest(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||
return errorResponse('Expected websocket', 426);
|
||||
}
|
||||
const payload = await authenticateNotificationsHub(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
|
||||
const userId = payload.sub;
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
handleRunAdminConfiguredBackup,
|
||||
handleUpdateAdminBackupSettings,
|
||||
} from './handlers/backup';
|
||||
import { errorResponse } from './utils/response';
|
||||
|
||||
export async function handleAdminBackupRoute(
|
||||
request: Request,
|
||||
@@ -26,8 +27,22 @@ export async function handleAdminBackupRoute(
|
||||
return handleAdminExportBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
|
||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||
if (path === '/api/admin/backup/blob') {
|
||||
// POST only: this endpoint requires master-password verification, and a GET
|
||||
// could only carry that credential in the query string, where it would leak
|
||||
// into request logs, proxy logs, browser history and Referer headers.
|
||||
// The credential is the same value clients send to /identity/connect/token,
|
||||
// so a leaked copy is enough to sign in as this admin.
|
||||
if (method === 'POST') {
|
||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||
}
|
||||
if (method === 'GET') {
|
||||
return errorResponse(
|
||||
'Use POST with a JSON body for this endpoint. Credentials must not be sent in the URL.',
|
||||
405
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/settings') {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { generateUUID } from './uuid';
|
||||
|
||||
const WEBSOCKET_NOTIFICATION_SCOPE = 'notifications.websocket';
|
||||
|
||||
export interface WebSocketConnectionTokenClaims {
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
nonce: string;
|
||||
scope: typeof WEBSOCKET_NOTIFICATION_SCOPE;
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...data))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): Uint8Array {
|
||||
let normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (normalized.length % 4) normalized += '=';
|
||||
const binary = atob(normalized);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function getSigningKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
export async function createWebSocketConnectionToken(
|
||||
userId: string,
|
||||
expiresAt: number,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const claims: WebSocketConnectionTokenClaims = {
|
||||
userId,
|
||||
expiresAt,
|
||||
nonce: generateUUID(),
|
||||
scope: WEBSOCKET_NOTIFICATION_SCOPE,
|
||||
};
|
||||
const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify(claims)));
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
await getSigningKey(secret),
|
||||
new TextEncoder().encode(payload)
|
||||
);
|
||||
return `${payload}.${base64UrlEncode(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export async function verifyWebSocketConnectionToken(
|
||||
token: string,
|
||||
secret: string
|
||||
): Promise<WebSocketConnectionTokenClaims | null> {
|
||||
try {
|
||||
if (!token || token.length > 1024) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [payload, encodedSignature] = parts;
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
await getSigningKey(secret),
|
||||
base64UrlDecode(encodedSignature),
|
||||
new TextEncoder().encode(payload)
|
||||
);
|
||||
if (!valid) return null;
|
||||
|
||||
const claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as Partial<WebSocketConnectionTokenClaims>;
|
||||
if (
|
||||
claims.scope !== WEBSOCKET_NOTIFICATION_SCOPE
|
||||
|| !String(claims.userId || '').trim()
|
||||
|| !Number.isFinite(claims.expiresAt)
|
||||
|| Number(claims.expiresAt) <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return claims as WebSocketConnectionTokenClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -1674,17 +1674,25 @@ export default function App() {
|
||||
reconnectAttempts += 1;
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
void connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const connect = async () => {
|
||||
if (disposed) return;
|
||||
const accessToken = session.accessToken;
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const negotiateResponse = await fetch('/notifications/hub/negotiate?negotiateVersion=1', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!negotiateResponse.ok) throw new Error('Notification negotiation failed');
|
||||
const negotiation = (await negotiateResponse.json()) as { connectionToken?: string };
|
||||
if (!negotiation.connectionToken || disposed) throw new Error('Notification connection token missing');
|
||||
|
||||
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
||||
hubUrl.searchParams.set('access_token', accessToken);
|
||||
hubUrl.searchParams.set('id', negotiation.connectionToken);
|
||||
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(hubUrl.toString());
|
||||
} catch {
|
||||
@@ -1809,7 +1817,7 @@ export default function App() {
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
|
||||
@@ -1,158 +1,77 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||
import { Check, Copy, Download, LoaderCircle, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
clampInteger,
|
||||
defaultGeneratorSettings,
|
||||
estimateStrength,
|
||||
generateValue,
|
||||
normalizeGeneratorSettings,
|
||||
type EmailMode,
|
||||
type EmailOptions,
|
||||
type GeneratorMode,
|
||||
type GeneratorSettings,
|
||||
type PassphraseOptions,
|
||||
type PasswordOptions,
|
||||
type PinOptions,
|
||||
type SshKeyOptions,
|
||||
type UsernameOptions,
|
||||
} from '@/lib/password-generator';
|
||||
import { generateSshKey, type GeneratedSshKey } from '@/lib/ssh-key-generator';
|
||||
|
||||
type GeneratorMode = 'password' | 'passphrase';
|
||||
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v2';
|
||||
|
||||
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 } {
|
||||
function readSettings(): GeneratorSettings {
|
||||
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),
|
||||
},
|
||||
};
|
||||
const current = localStorage.getItem(SETTINGS_KEY);
|
||||
if (current) return normalizeGeneratorSettings(JSON.parse(current));
|
||||
|
||||
// Preserve compatible options for users upgrading from the original generator.
|
||||
const legacy = JSON.parse(localStorage.getItem('nodewarden.passwordGenerator.settings.v1') || '{}');
|
||||
return normalizeGeneratorSettings(legacy);
|
||||
} catch {
|
||||
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
|
||||
return defaultGeneratorSettings;
|
||||
}
|
||||
}
|
||||
|
||||
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 [settings, setSettings] = useState<GeneratorSettings>(initial);
|
||||
const [seed, setSeed] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [sshKey, setSshKey] = useState<GeneratedSshKey | null>(null);
|
||||
const [sshKeyError, setSshKeyError] = useState('');
|
||||
const [sshKeyLoading, setSshKeyLoading] = useState(false);
|
||||
|
||||
const generated = useMemo(
|
||||
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
|
||||
[mode, passwordOptions, passphraseOptions, seed]
|
||||
const generated = useMemo(() => settings.mode === 'sshKey' ? sshKey?.fingerprint || '' : generateValue(settings), [settings, seed, sshKey]);
|
||||
const strength = useMemo(
|
||||
() => estimateStrength(settings.mode, generated, settings.mode === 'passphrase' ? settings.passphrase.words : undefined),
|
||||
[generated, settings.mode, settings.passphrase.words],
|
||||
);
|
||||
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
|
||||
const strengthLabel = strength
|
||||
? t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][strength - 1])
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// The generator remains fully usable when browser storage is unavailable.
|
||||
}
|
||||
}, [mode, passwordOptions, passphraseOptions]);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.mode !== 'sshKey') return;
|
||||
let cancelled = false;
|
||||
setSshKeyLoading(true);
|
||||
setSshKeyError('');
|
||||
void generateSshKey({ ...settings.sshKey, comment: '' })
|
||||
.then((value) => { if (!cancelled) setSshKey(value); })
|
||||
.catch(() => { if (!cancelled) { setSshKey(null); setSshKeyError(t('txt_generator_ssh_error')); } })
|
||||
.finally(() => { if (!cancelled) setSshKeyLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [settings.mode, settings.sshKey.type, settings.sshKey.rsaLength, seed]);
|
||||
|
||||
const regenerate = () => {
|
||||
setCopied(false);
|
||||
@@ -160,17 +79,49 @@ export default function PasswordGeneratorPage() {
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||
const value = settings.mode === 'sshKey' && sshKey ? publicKeyWithComment(sshKey.publicKey, settings.sshKey.comment) : generated;
|
||||
await copyTextToClipboard(value, { 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 }));
|
||||
const changeMode = (mode: GeneratorMode) => {
|
||||
setSettings((current) => ({ ...current, mode }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, password: { ...current.password, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changeCharacterType = (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => {
|
||||
const enabled = ['uppercase', 'lowercase', 'numbers', 'special'].filter((item) => settings.password[item as 'uppercase']);
|
||||
if (!checked && enabled.length === 1 && enabled[0] === key) return;
|
||||
changePasswordOption(key, checked);
|
||||
};
|
||||
|
||||
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
|
||||
setPassphraseOptions((current) => ({ ...current, [key]: value }));
|
||||
setSettings((current) => ({ ...current, passphrase: { ...current.passphrase, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changePinOption = <K extends keyof PinOptions>(key: K, value: PinOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, pin: { ...current.pin, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changeUsernameOption = <K extends keyof UsernameOptions>(key: K, value: UsernameOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, username: { ...current.username, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changeEmailOption = <K extends keyof EmailOptions>(key: K, value: EmailOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, email: { ...current.email, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changeSshKeyOption = <K extends keyof SshKeyOptions>(key: K, value: SshKeyOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, sshKey: { ...current.sshKey, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
@@ -178,62 +129,182 @@ export default function PasswordGeneratorPage() {
|
||||
<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 className="settings-category-tabs generator-mode-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
||||
{([
|
||||
['password', 'txt_password'],
|
||||
['passphrase', 'txt_passphrase'],
|
||||
['pin', 'txt_generator_pin'],
|
||||
['username', 'txt_generator_username'],
|
||||
['email', 'txt_generator_email_alias'],
|
||||
['sshKey', 'txt_generator_ssh_key'],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button key={mode} type="button" role="tab" aria-selected={settings.mode === mode} className={`settings-category-tab ${settings.mode === mode ? 'active' : ''}`} onClick={() => changeMode(mode)}>{t(label)}</button>
|
||||
))}
|
||||
</div>
|
||||
{settings.mode === 'sshKey' ? (
|
||||
<SshKeyOutput value={sshKey} loading={sshKeyLoading} error={sshKeyError} comment={settings.sshKey.comment} />
|
||||
) : <output className={`generator-value ${generated ? '' : 'empty'}`} aria-label={t('txt_generated_value')}>{generated || t('txt_generator_email_required_hint')}</output>}
|
||||
{settings.mode !== 'sshKey' && <div className="generator-meta-row">
|
||||
{strength > 0 ? (
|
||||
<>
|
||||
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strengthLabel}`}>
|
||||
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength ? `active level-${strength}` : ''} />)}
|
||||
</div>
|
||||
<span><ShieldCheck size={15} /> {strengthLabel}</span>
|
||||
</>
|
||||
) : <span />}
|
||||
<span>{t('txt_generator_character_count', { count: generated.length })}</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>
|
||||
<button type="button" className="btn btn-primary" disabled={sshKeyLoading} onClick={regenerate}>{sshKeyLoading ? <LoaderCircle size={16} className="btn-icon generator-spinner" /> : <RefreshCw size={16} className="btn-icon" />}{t('txt_regenerate')}</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={(settings.mode === 'sshKey' && !sshKey) || !generated} onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : settings.mode === 'sshKey' ? t('txt_generator_copy_public_key') : t('txt_copy')}</button>
|
||||
</div>
|
||||
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
|
||||
<p className="generator-security-note"><Check size={15} />{t(settings.mode === 'sshKey' ? 'txt_generator_ssh_security_note' : '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' ? (
|
||||
{settings.mode === 'password' && (
|
||||
<PasswordOptionFields options={settings.password} onChange={changePasswordOption} onCharacterTypeChange={changeCharacterType} />
|
||||
)}
|
||||
{settings.mode === 'passphrase' && (
|
||||
<>
|
||||
<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>
|
||||
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={settings.passphrase.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||
<label className="generator-select-field" htmlFor="generator-word-list"><span>{t('txt_generator_word_list')}</span><select id="generator-word-list" className="input" value={settings.passphrase.wordList} onChange={(event) => changePassphraseOption('wordList', event.currentTarget.value as 'eff' | 'custom')}><option value="eff">{t('txt_generator_eff_word_list')}</option><option value="custom">{t('txt_generator_custom_word_list')}</option></select></label>
|
||||
{settings.passphrase.wordList === 'custom' && <label className="generator-text-field" htmlFor="generator-custom-words"><span>{t('txt_generator_custom_words')}</span><textarea id="generator-custom-words" className="input generator-word-list-input" rows={6} spellcheck={false} placeholder={t('txt_generator_custom_words_placeholder')} value={settings.passphrase.customWords} onInput={(event) => changePassphraseOption('customWords', event.currentTarget.value)} /></label>}
|
||||
<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={settings.passphrase.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')} />
|
||||
<GeneratorToggle checked={settings.passphrase.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||
<GeneratorToggle checked={settings.passphrase.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{settings.mode === 'pin' && (
|
||||
<>
|
||||
<GeneratorNumberStepper id="pin-length" label={t('txt_generator_length')} value={settings.pin.length} minimum={3} maximum={64} fallback={6} onChange={(value) => changePinOption('length', value)} />
|
||||
<p className="generator-options-note">{t('txt_generator_pin_description')}</p>
|
||||
</>
|
||||
)}
|
||||
{settings.mode === 'username' && (
|
||||
<UsernameOptionFields options={settings.username} onChange={changeUsernameOption} />
|
||||
)}
|
||||
{settings.mode === 'email' && (
|
||||
<EmailOptionFields options={settings.email} onChange={changeEmailOption} />
|
||||
)}
|
||||
{settings.mode === 'sshKey' && (
|
||||
<SshKeyOptionFields options={settings.sshKey} onChange={changeSshKeyOption} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordOptionFields(props: { options: PasswordOptions; onChange: <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => void; onCharacterTypeChange: (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => void }) {
|
||||
const { options } = props;
|
||||
return (
|
||||
<>
|
||||
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={options.length} minimum={5} maximum={128} fallback={16} onChange={(value) => props.onChange('length', value)} />
|
||||
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||
<GeneratorToggle checked={options.uppercase} onChange={(checked) => props.onCharacterTypeChange('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||
{options.uppercase && <GeneratorNumberStepper id="min-uppercase" compact label={t('txt_generator_minimum')} value={options.minUppercase} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minUppercase', value)} />}
|
||||
<GeneratorToggle checked={options.lowercase} onChange={(checked) => props.onCharacterTypeChange('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||
{options.lowercase && <GeneratorNumberStepper id="min-lowercase" compact label={t('txt_generator_minimum')} value={options.minLowercase} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minLowercase', value)} />}
|
||||
<GeneratorToggle checked={options.numbers} onChange={(checked) => props.onCharacterTypeChange('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||
{options.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={options.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minNumbers', value)} />}
|
||||
<GeneratorToggle checked={options.special} onChange={(checked) => props.onCharacterTypeChange('special', checked)} label={t('txt_generator_special')} />
|
||||
{options.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={options.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minSpecial', value)} />}
|
||||
</fieldset>
|
||||
<GeneratorToggle checked={options.avoidAmbiguous} onChange={(checked) => props.onChange('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UsernameOptionFields(props: { options: UsernameOptions; onChange: <K extends keyof UsernameOptions>(key: K, value: UsernameOptions[K]) => void }) {
|
||||
return (
|
||||
<>
|
||||
<GeneratorNumberStepper id="username-words" label={t('txt_generator_words')} value={props.options.words} minimum={1} maximum={10} fallback={2} onChange={(value) => props.onChange('words', value)} />
|
||||
<div className="generator-option-group">
|
||||
<GeneratorToggle checked={props.options.capitalize} onChange={(checked) => props.onChange('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||
<GeneratorToggle checked={props.options.includeNumber} onChange={(checked) => props.onChange('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||
</div>
|
||||
<label className="generator-select-field" htmlFor="generator-username-word-list"><span>{t('txt_generator_word_list')}</span><select id="generator-username-word-list" className="input" value={props.options.wordList} onChange={(event) => props.onChange('wordList', event.currentTarget.value as 'eff' | 'custom')}><option value="eff">{t('txt_generator_eff_word_list')}</option><option value="custom">{t('txt_generator_custom_word_list')}</option></select></label>
|
||||
{props.options.wordList === 'custom' && <label className="generator-text-field" htmlFor="generator-username-custom-words"><span>{t('txt_generator_custom_words')}</span><textarea id="generator-username-custom-words" className="input generator-word-list-input" rows={6} spellcheck={false} placeholder={t('txt_generator_custom_words_placeholder')} value={props.options.customWords} onInput={(event) => props.onChange('customWords', event.currentTarget.value)} /></label>}
|
||||
<label className="generator-text-field" htmlFor="generator-username-custom-word"><span>{t('txt_generator_custom_word')}</span><input id="generator-username-custom-word" className="input" type="text" autocomplete="off" maxLength={128} value={props.options.customWord} onInput={(event) => props.onChange('customWord', event.currentTarget.value)} /></label>
|
||||
<label className="generator-text-field" htmlFor="generator-username-delimiter"><span>{t('txt_generator_separator')}</span><input id="generator-username-delimiter" className="input" type="text" autocomplete="off" maxLength={8} value={props.options.delimiter} onInput={(event) => props.onChange('delimiter', event.currentTarget.value.slice(0, 8))} /></label>
|
||||
<p className="generator-options-note">{t('txt_generator_long_word_username_description')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailOptionFields(props: { options: EmailOptions; onChange: <K extends keyof EmailOptions>(key: K, value: EmailOptions[K]) => void }) {
|
||||
const types: Array<[EmailMode, string]> = [
|
||||
['plusAddressed', 'txt_generator_plus_addressed_email'],
|
||||
['catchAll', 'txt_generator_catch_all_email'],
|
||||
['subdomain', 'txt_generator_subdomain_email'],
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<label className="generator-select-field" htmlFor="generator-email-type"><span>{t('txt_generator_email_type')}</span><select id="generator-email-type" className="input" value={props.options.type} onChange={(event) => props.onChange('type', event.currentTarget.value as EmailMode)}>{types.map(([value, label]) => <option key={value} value={value}>{t(label)}</option>)}</select></label>
|
||||
{props.options.type === 'catchAll'
|
||||
? <label className="generator-text-field" htmlFor="generator-domain"><span>{t('txt_generator_domain')}</span><input id="generator-domain" className="input" type="text" autocomplete="off" value={props.options.domain} onInput={(event) => props.onChange('domain', event.currentTarget.value)} /></label>
|
||||
: <label className="generator-text-field" htmlFor="generator-email"><span>{t('txt_generator_email')}</span><input id="generator-email" className="input" type="email" autocomplete="off" value={props.options.email} onInput={(event) => props.onChange('email', event.currentTarget.value)} /></label>}
|
||||
<p className="generator-options-note">{t('txt_generator_email_description')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function publicKeyWithComment(publicKey: string, comment: string): string {
|
||||
const base = publicKey.trim().split(/\s+/).slice(0, 2).join(' ');
|
||||
const safeComment = comment.replace(/[\r\n]+/g, ' ').trim();
|
||||
return safeComment ? `${base} ${safeComment}` : base;
|
||||
}
|
||||
|
||||
function downloadText(filename: string, value: string): void {
|
||||
const url = URL.createObjectURL(new Blob([value], { type: 'text/plain;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function SshKeyOutput(props: { value: GeneratedSshKey | null; loading: boolean; error: string; comment: string }) {
|
||||
if (props.loading) return <div className="generator-key-status"><LoaderCircle size={24} className="generator-spinner" /><span>{t('txt_generator_ssh_generating')}</span></div>;
|
||||
if (props.error) return <div className="generator-key-status error">{props.error}</div>;
|
||||
if (!props.value) return null;
|
||||
const publicKey = publicKeyWithComment(props.value.publicKey, props.comment);
|
||||
const copyField = (value: string) => void copyTextToClipboard(value);
|
||||
return (
|
||||
<div className="generator-key-output">
|
||||
<div className="generator-key-summary"><strong>{props.value.type}{props.value.type === 'RSA' ? ` ${props.value.bits}` : ''}</strong><code>{props.value.fingerprint}</code></div>
|
||||
<div className="generator-key-field"><span>{t('txt_generator_public_key')}</span><code>{publicKey}</code><div className="generator-key-field-actions"><button type="button" className="btn btn-secondary small" onClick={() => copyField(publicKey)}><Copy size={14} />{t('txt_copy')}</button><button type="button" className="btn btn-secondary small" onClick={() => downloadText('id_nodewarden.pub', `${publicKey}\n`)}><Download size={14} />{t('txt_download')}</button></div></div>
|
||||
<details className="generator-private-key"><summary>{t('txt_generator_private_key')}</summary><code>{props.value.privateKey}</code><div className="generator-key-field-actions"><button type="button" className="btn btn-secondary small" onClick={() => copyField(props.value!.privateKey)}><Copy size={14} />{t('txt_copy')}</button><button type="button" className="btn btn-secondary small" onClick={() => downloadText('id_nodewarden', props.value!.privateKey)}><Download size={14} />{t('txt_download')}</button></div></details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SshKeyOptionFields(props: { options: SshKeyOptions; onChange: <K extends keyof SshKeyOptions>(key: K, value: SshKeyOptions[K]) => void }) {
|
||||
return (
|
||||
<>
|
||||
<label className="generator-select-field" htmlFor="generator-ssh-type"><span>{t('txt_generator_ssh_algorithm')}</span><select id="generator-ssh-type" className="input" value={props.options.type} onChange={(event) => props.onChange('type', event.currentTarget.value as SshKeyOptions['type'])}><option value="ed25519">Ed25519</option><option value="rsa">RSA</option></select></label>
|
||||
{props.options.type === 'rsa' && <label className="generator-select-field" htmlFor="generator-rsa-length"><span>{t('txt_generator_key_length')}</span><select id="generator-rsa-length" className="input" value={props.options.rsaLength} onChange={(event) => props.onChange('rsaLength', Number(event.currentTarget.value) as SshKeyOptions['rsaLength'])}><option value={2048}>2048</option><option value={3072}>3072</option><option value={4096}>4096</option></select></label>}
|
||||
<label className="generator-text-field" htmlFor="generator-ssh-comment"><span>{t('txt_generator_ssh_comment')}</span><input id="generator-ssh-comment" className="input" type="text" autocomplete="off" maxLength={256} placeholder="user@example.com" value={props.options.comment} onInput={(event) => props.onChange('comment', event.currentTarget.value)} /></label>
|
||||
<p className="generator-options-note">{t(props.options.type === 'rsa' ? 'txt_generator_ssh_rsa_description' : 'txt_generator_ssh_ed25519_description')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
const setValue = (value: number) => props.onChange(clampInteger(value, props.minimum, props.maximum, props.fallback));
|
||||
return (
|
||||
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
|
||||
<label htmlFor={id}>{props.label}</label>
|
||||
|
||||
@@ -382,7 +382,9 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}
|
||||
const groupIndexByKey = new Map<string, number>();
|
||||
Array.from(groupKeys).sort().forEach((groupKey, index) => {
|
||||
groupIndexByKey.set(groupKey, index % 64);
|
||||
// Keep indices unique (no modulo): they are used both for group colors and
|
||||
// as the group identity when selecting duplicate items to delete.
|
||||
groupIndexByKey.set(groupKey, index);
|
||||
});
|
||||
const byId = new Map<string, number>();
|
||||
for (const [cipherId, groupKey] of groupKeyById.entries()) {
|
||||
|
||||
@@ -197,7 +197,7 @@ export async function downloadAdminBackupAttachmentBlob(
|
||||
authedFetch: AuthedFetch,
|
||||
blobName: string,
|
||||
masterPasswordHash: string
|
||||
): Promise<Uint8Array> {
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const resp = await authedFetch('/api/admin/backup/blob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -186,7 +186,7 @@ export function invalidateRemoteBrowserCacheForDestination(
|
||||
cache: Record<string, RemoteBackupBrowserResponse>,
|
||||
pathByDestination: Record<string, string>,
|
||||
pageByKey: Record<string, number>
|
||||
): PersistedRemoteBrowserState {
|
||||
): Omit<PersistedRemoteBrowserState, 'refreshedAt'> {
|
||||
return {
|
||||
cache: Object.fromEntries(Object.entries(cache).filter(([key]) => !key.startsWith(`${destinationId}:`))),
|
||||
pathByDestination: Object.fromEntries(Object.entries(pathByDestination).filter(([key]) => key !== destinationId)),
|
||||
|
||||
@@ -8,6 +8,10 @@ const de: Record<string, string> = {
|
||||
"nav_vault_items": "Tresor",
|
||||
"nav_sends": "Sendungen",
|
||||
"nav_generator": "Passwortwerkzeug", "txt_password_generator": "Passwortgenerator", "txt_password_generator_description": "Erstellen Sie lokal auf diesem Gerät ein starkes, einzigartiges Passwort.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Kennwortsatz", "txt_generated_password": "Generiertes Passwort", "txt_password_strength": "Stärke", "txt_password_strength_weak": "Schwach", "txt_password_strength_fair": "Mittel", "txt_password_strength_good": "Gut", "txt_password_strength_strong": "Stark", "txt_generator_security_note": "Die Erzeugung erfolgt lokal. Ihr Passwort wird nie an den Server gesendet.", "txt_generator_length": "Länge", "txt_generator_character_types": "Zeichentypen", "txt_generator_uppercase": "Großbuchstaben (A-Z)", "txt_generator_lowercase": "Kleinbuchstaben (a-z)", "txt_generator_numbers": "Zahlen (0-9)", "txt_generator_special": "Sonderzeichen (!@#$%^&*)", "txt_generator_minimum": "Mindestanzahl", "txt_generator_avoid_ambiguous": "Verwechselbare Zeichen vermeiden", "txt_generator_words": "Anzahl der Wörter", "txt_generator_separator": "Worttrenner", "txt_generator_capitalize": "Großschreibung", "txt_generator_include_number": "Eine Zahl einfügen",
|
||||
"txt_generated_value": "Erzeugter Wert", "txt_generator_character_count": "{count} Zeichen", "txt_generator_pin": "PIN-Code", "txt_generator_pin_description": "Erzeugt lokal eine numerische PIN mit kryptografisch sicheren Zufallswerten.", "txt_generator_username": "Benutzername", "txt_generator_plus_addressed_email": "E-Mail mit Plus-Adressierung", "txt_generator_catch_all_email": "Catch-all-E-Mail", "txt_generator_email": "E-Mail-Adresse", "txt_generator_domain": "Domäne",
|
||||
"txt_generator_word_list": "Wortliste", "txt_generator_eff_word_list": "Lange EFF-Wortliste", "txt_generator_custom_word_list": "Eigene Wortliste", "txt_generator_custom_words": "Eigene Wörter", "txt_generator_custom_words_placeholder": "Mindestens zwei Wörter durch Leerzeichen, Kommas oder Zeilen trennen",
|
||||
"txt_generator_long_word_username": "Langwort-Benutzername", "txt_generator_custom_word": "Eigenes Merkwort", "txt_generator_subdomain_email": "Subdomain-E-Mail", "txt_generator_ssh_key": "SSH-Schlüssel", "txt_generator_ssh_generating": "SSH-Schlüssel wird erzeugt…", "txt_generator_ssh_error": "Dieser Browser unterstützt keine SSH-Schlüsselerzeugung.", "txt_generator_copy_public_key": "Öffentlichen Schlüssel kopieren", "txt_generator_ssh_security_note": "Das Schlüsselpaar wird lokal erzeugt und nicht gesendet. Speichern Sie den privaten Schlüssel jetzt; NodeWarden behält ihn nicht.", "txt_generator_public_key": "Öffentlicher Schlüssel", "txt_generator_private_key": "Privater Schlüssel — geheim halten", "txt_generator_ssh_algorithm": "Algorithmus", "txt_generator_key_length": "Schlüssellänge", "txt_generator_ssh_comment": "Kommentar des öffentlichen Schlüssels", "txt_generator_ssh_rsa_description": "RSA ist breit kompatibel. Mindestens 2048 Bit verwenden; 4096 Bit werden empfohlen.", "txt_generator_ssh_ed25519_description": "Ed25519 ist schnell, kompakt und für moderne SSH-Clients empfohlen.",
|
||||
"txt_generator_email_alias": "E-Mail-Alias", "txt_generator_email_type": "Alias-Typ", "txt_generator_email_required_hint": "E-Mail-Adresse oder Domain eingeben, um einen Alias zu erzeugen.", "txt_generator_email_description": "Ihre Eingabe bleibt auf diesem Gerät und wird nur zum Zusammensetzen des Alias verwendet.", "txt_generator_long_word_username_description": "Ein oder mehrere lange Wörter zu einem einprägsamen Benutzernamen kombinieren.",
|
||||
"nav_backup_strategy": "Cloud-Backup",
|
||||
"nav_import_export": "Import und Export",
|
||||
"nav_group_data_backup": "Daten & Backup",
|
||||
|
||||
@@ -31,6 +31,40 @@ const en: Record<string, string> = {
|
||||
"txt_generator_separator": "Word separator",
|
||||
"txt_generator_capitalize": "Capitalize",
|
||||
"txt_generator_include_number": "Include a number",
|
||||
"txt_generated_value": "Generated value",
|
||||
"txt_generator_character_count": "{count} characters",
|
||||
"txt_generator_pin": "PIN",
|
||||
"txt_generator_pin_description": "Generate a numeric PIN locally using cryptographically secure randomness.",
|
||||
"txt_generator_username": "Username",
|
||||
"txt_generator_plus_addressed_email": "Plus-addressed email",
|
||||
"txt_generator_catch_all_email": "Catch-all email",
|
||||
"txt_generator_email": "Email address",
|
||||
"txt_generator_domain": "Domain",
|
||||
"txt_generator_word_list": "Word list",
|
||||
"txt_generator_eff_word_list": "EFF long word list",
|
||||
"txt_generator_custom_word_list": "Custom word list",
|
||||
"txt_generator_custom_words": "Custom words",
|
||||
"txt_generator_custom_words_placeholder": "Enter at least two words separated by spaces, commas, or new lines",
|
||||
"txt_generator_long_word_username": "Long-word username",
|
||||
"txt_generator_custom_word": "Custom helper word",
|
||||
"txt_generator_subdomain_email": "Subdomain email",
|
||||
"txt_generator_ssh_key": "SSH key",
|
||||
"txt_generator_ssh_generating": "Generating SSH key…",
|
||||
"txt_generator_ssh_error": "SSH key generation is not supported by this browser.",
|
||||
"txt_generator_copy_public_key": "Copy public key",
|
||||
"txt_generator_ssh_security_note": "The key pair is generated locally and is never sent to the server. Save the private key now; NodeWarden does not retain it.",
|
||||
"txt_generator_public_key": "Public key",
|
||||
"txt_generator_private_key": "Private key — keep secret",
|
||||
"txt_generator_ssh_algorithm": "Algorithm",
|
||||
"txt_generator_key_length": "Key length",
|
||||
"txt_generator_ssh_comment": "Public key comment",
|
||||
"txt_generator_ssh_rsa_description": "RSA is widely compatible. Use at least 2048 bits; 4096 bits is recommended.",
|
||||
"txt_generator_ssh_ed25519_description": "Ed25519 is fast, compact, and recommended for modern SSH clients.",
|
||||
"txt_generator_email_alias": "Email alias",
|
||||
"txt_generator_email_type": "Email alias type",
|
||||
"txt_generator_email_required_hint": "Enter your email address or domain to generate an alias.",
|
||||
"txt_generator_email_description": "Your address stays on this device and is only used to compose the alias.",
|
||||
"txt_generator_long_word_username_description": "Combine one or more long words into a memorable username.",
|
||||
"nav_backup_strategy": "Cloud Backup",
|
||||
"nav_import_export": "Import & Export",
|
||||
"nav_group_data_backup": "Data & Backup",
|
||||
|
||||
@@ -8,6 +8,10 @@ const es: Record<string, string> = {
|
||||
"nav_vault_items": "Bóveda",
|
||||
"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",
|
||||
"txt_generated_value": "Valor generado", "txt_generator_character_count": "{count} caracteres", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Genera localmente un PIN numérico con aleatoriedad criptográficamente segura.", "txt_generator_username": "Nombre de usuario", "txt_generator_plus_addressed_email": "Correo con direccionamiento plus", "txt_generator_catch_all_email": "Correo catch-all", "txt_generator_email": "Correo electrónico", "txt_generator_domain": "Dominio",
|
||||
"txt_generator_word_list": "Lista de palabras", "txt_generator_eff_word_list": "Lista larga de EFF", "txt_generator_custom_word_list": "Lista personalizada", "txt_generator_custom_words": "Palabras personalizadas", "txt_generator_custom_words_placeholder": "Introduce al menos dos palabras separadas por espacios, comas o líneas",
|
||||
"txt_generator_long_word_username": "Usuario de palabras largas", "txt_generator_custom_word": "Palabra auxiliar personalizada", "txt_generator_subdomain_email": "Correo de subdominio", "txt_generator_ssh_key": "Clave SSH", "txt_generator_ssh_generating": "Generando clave SSH…", "txt_generator_ssh_error": "Este navegador no admite la generación de claves SSH.", "txt_generator_copy_public_key": "Copiar clave pública", "txt_generator_ssh_security_note": "El par se genera localmente y no se envía. Guarda ahora la clave privada; NodeWarden no la conserva.", "txt_generator_public_key": "Clave pública", "txt_generator_private_key": "Clave privada — mantenla en secreto", "txt_generator_ssh_algorithm": "Algoritmo", "txt_generator_key_length": "Longitud de clave", "txt_generator_ssh_comment": "Comentario de clave pública", "txt_generator_ssh_rsa_description": "RSA es ampliamente compatible. Usa al menos 2048 bits; se recomiendan 4096.", "txt_generator_ssh_ed25519_description": "Ed25519 es rápido, compacto y recomendado para clientes SSH modernos.",
|
||||
"txt_generator_email_alias": "Alias de correo", "txt_generator_email_type": "Tipo de alias", "txt_generator_email_required_hint": "Introduce tu correo o dominio para generar un alias.", "txt_generator_email_description": "Los datos permanecen en este dispositivo y solo se usan para formar el alias.", "txt_generator_long_word_username_description": "Combina una o más palabras largas en un nombre de usuario fácil de recordar.",
|
||||
"nav_backup_strategy": "Copia de seguridad en la nube",
|
||||
"nav_import_export": "Importar y exportar",
|
||||
"nav_group_data_backup": "Datos y copias",
|
||||
|
||||
@@ -8,6 +8,10 @@ const fi: Record<string, string> = {
|
||||
"nav_vault_items": "Holvi",
|
||||
"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",
|
||||
"txt_generated_value": "Luotu arvo", "txt_generator_character_count": "{count} merkkiä", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Luo numeerisen PIN-koodin paikallisesti kryptografisesti turvallisella satunnaisuudella.", "txt_generator_username": "Käyttäjänimi", "txt_generator_plus_addressed_email": "Plus-osoitteinen sähköposti", "txt_generator_catch_all_email": "Catch-all-sähköposti", "txt_generator_email": "Sähköpostiosoite", "txt_generator_domain": "Verkkotunnus",
|
||||
"txt_generator_word_list": "Sanalista", "txt_generator_eff_word_list": "Pitkä EFF-sanalista", "txt_generator_custom_word_list": "Oma sanalista", "txt_generator_custom_words": "Omat sanat", "txt_generator_custom_words_placeholder": "Anna vähintään kaksi sanaa välilyönnein, pilkuin tai riveittäin",
|
||||
"txt_generator_long_word_username": "Pitkäsanainen käyttäjänimi", "txt_generator_custom_word": "Oma muistisanaa", "txt_generator_subdomain_email": "Aliverkkotunnuksen sähköposti", "txt_generator_ssh_key": "SSH-avain", "txt_generator_ssh_generating": "Luodaan SSH-avainta…", "txt_generator_ssh_error": "Selain ei tue SSH-avaimen luontia.", "txt_generator_copy_public_key": "Kopioi julkinen avain", "txt_generator_ssh_security_note": "Avainpari luodaan paikallisesti eikä sitä lähetetä. Tallenna yksityinen avain nyt; NodeWarden ei säilytä sitä.", "txt_generator_public_key": "Julkinen avain", "txt_generator_private_key": "Yksityinen avain — pidä salassa", "txt_generator_ssh_algorithm": "Algoritmi", "txt_generator_key_length": "Avaimen pituus", "txt_generator_ssh_comment": "Julkisen avaimen kommentti", "txt_generator_ssh_rsa_description": "RSA on laajasti yhteensopiva. Käytä vähintään 2048 bittiä; 4096 on suositus.", "txt_generator_ssh_ed25519_description": "Ed25519 on nopea ja kompakti sekä suositeltu moderneille SSH-asiakkaille.",
|
||||
"txt_generator_email_alias": "Sähköpostialias", "txt_generator_email_type": "Aliaksen tyyppi", "txt_generator_email_required_hint": "Anna sähköpostiosoite tai verkkotunnus aliaksen luomiseksi.", "txt_generator_email_description": "Syöte pysyy tällä laitteella ja sitä käytetään vain aliaksen muodostamiseen.", "txt_generator_long_word_username_description": "Yhdistä yksi tai useampi pitkä sana helposti muistettavaksi käyttäjänimeksi.",
|
||||
"nav_backup_strategy": "Pilvivarmuuskopiointi",
|
||||
"nav_import_export": "Tuonti ja Vienti",
|
||||
"nav_group_data_backup": "Data & Varmuuskopiointi",
|
||||
|
||||
@@ -8,6 +8,10 @@ const fr: Record<string, string> = {
|
||||
"nav_vault_items": "Coffre-fort",
|
||||
"nav_sends": "Envois",
|
||||
"nav_generator": "Générateur", "txt_password_generator": "Générateur de mots de passe", "txt_password_generator_description": "Créez un mot de passe fort et unique localement sur cet appareil.", "txt_generator_type": "Type de générateur", "txt_passphrase": "Phrase secrète", "txt_generated_password": "Mot de passe généré", "txt_password_strength": "Robustesse", "txt_password_strength_weak": "Faible", "txt_password_strength_fair": "Correcte", "txt_password_strength_good": "Bonne", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La génération est locale. Votre mot de passe n'est jamais envoyé au serveur.", "txt_generator_length": "Longueur", "txt_generator_character_types": "Types de caractères", "txt_generator_uppercase": "Majuscules (A-Z)", "txt_generator_lowercase": "Minuscules (a-z)", "txt_generator_numbers": "Chiffres (0-9)", "txt_generator_special": "Caractères spéciaux (!@#$%^&*)", "txt_generator_minimum": "Minimum", "txt_generator_avoid_ambiguous": "Éviter les caractères ambigus", "txt_generator_words": "Nombre de mots", "txt_generator_separator": "Séparateur de mots", "txt_generator_capitalize": "Mettre une majuscule", "txt_generator_include_number": "Inclure un chiffre",
|
||||
"txt_generated_value": "Valeur générée", "txt_generator_character_count": "{count} caractères", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Génère localement un PIN numérique avec un aléa cryptographiquement sûr.", "txt_generator_username": "Nom d’utilisateur", "txt_generator_plus_addressed_email": "Adresse e-mail avec plus", "txt_generator_catch_all_email": "Adresse e-mail catch-all", "txt_generator_email": "Adresse e-mail", "txt_generator_domain": "Domaine",
|
||||
"txt_generator_word_list": "Liste de mots", "txt_generator_eff_word_list": "Liste longue EFF", "txt_generator_custom_word_list": "Liste personnalisée", "txt_generator_custom_words": "Mots personnalisés", "txt_generator_custom_words_placeholder": "Saisissez au moins deux mots séparés par des espaces, virgules ou lignes",
|
||||
"txt_generator_long_word_username": "Identifiant à mots longs", "txt_generator_custom_word": "Mot aide-mémoire personnalisé", "txt_generator_subdomain_email": "E-mail de sous-domaine", "txt_generator_ssh_key": "Clé SSH", "txt_generator_ssh_generating": "Génération de la clé SSH…", "txt_generator_ssh_error": "Ce navigateur ne prend pas en charge la génération de clés SSH.", "txt_generator_copy_public_key": "Copier la clé publique", "txt_generator_ssh_security_note": "La paire est générée localement et jamais envoyée. Enregistrez la clé privée maintenant ; NodeWarden ne la conserve pas.", "txt_generator_public_key": "Clé publique", "txt_generator_private_key": "Clé privée — à garder secrète", "txt_generator_ssh_algorithm": "Algorithme", "txt_generator_key_length": "Longueur de clé", "txt_generator_ssh_comment": "Commentaire de clé publique", "txt_generator_ssh_rsa_description": "RSA est largement compatible. Utilisez au moins 2048 bits ; 4096 sont recommandés.", "txt_generator_ssh_ed25519_description": "Ed25519 est rapide, compact et recommandé pour les clients SSH modernes.",
|
||||
"txt_generator_email_alias": "Alias e-mail", "txt_generator_email_type": "Type d’alias", "txt_generator_email_required_hint": "Saisissez votre adresse e-mail ou domaine pour générer un alias.", "txt_generator_email_description": "La saisie reste sur cet appareil et sert uniquement à composer l’alias.", "txt_generator_long_word_username_description": "Combinez un ou plusieurs mots longs en un identifiant mémorable.",
|
||||
"nav_backup_strategy": "Sauvegarde Cloud",
|
||||
"nav_import_export": "Importer & Exporter",
|
||||
"nav_group_data_backup": "Données & Sauvegarde",
|
||||
|
||||
@@ -8,6 +8,10 @@ const it: Record<string, string> = {
|
||||
"nav_vault_items": "Cassaforte",
|
||||
"nav_sends": "Invii",
|
||||
"nav_generator": "Generatore", "txt_password_generator": "Generatore di password", "txt_password_generator_description": "Crea una password forte e univoca localmente su questo dispositivo.", "txt_generator_type": "Tipo di generatore", "txt_passphrase": "Frase segreta", "txt_generated_password": "Password generata", "txt_password_strength": "Robustezza", "txt_password_strength_weak": "Debole", "txt_password_strength_fair": "Discreta", "txt_password_strength_good": "Buona", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La generazione avviene localmente. La password non viene mai inviata al server.", "txt_generator_length": "Lunghezza", "txt_generator_character_types": "Tipi di caratteri", "txt_generator_uppercase": "Maiuscole (A-Z)", "txt_generator_lowercase": "Minuscole (a-z)", "txt_generator_numbers": "Numeri (0-9)", "txt_generator_special": "Caratteri speciali (!@#$%^&*)", "txt_generator_minimum": "Minimo", "txt_generator_avoid_ambiguous": "Evita caratteri ambigui", "txt_generator_words": "Numero di parole", "txt_generator_separator": "Separatore di parole", "txt_generator_capitalize": "Iniziale maiuscola", "txt_generator_include_number": "Includi un numero",
|
||||
"txt_generated_value": "Valore generato", "txt_generator_character_count": "{count} caratteri", "txt_generator_pin": "Codice PIN", "txt_generator_pin_description": "Genera localmente un PIN numerico con casualità crittograficamente sicura.", "txt_generator_username": "Nome utente", "txt_generator_plus_addressed_email": "Email con indirizzamento plus", "txt_generator_catch_all_email": "Email catch-all", "txt_generator_email": "Indirizzo email", "txt_generator_domain": "Dominio",
|
||||
"txt_generator_word_list": "Elenco parole", "txt_generator_eff_word_list": "Elenco lungo EFF", "txt_generator_custom_word_list": "Elenco personalizzato", "txt_generator_custom_words": "Parole personalizzate", "txt_generator_custom_words_placeholder": "Inserisci almeno due parole separate da spazi, virgole o righe",
|
||||
"txt_generator_long_word_username": "Nome utente a parole lunghe", "txt_generator_custom_word": "Parola mnemonica personalizzata", "txt_generator_subdomain_email": "Email con sottodominio", "txt_generator_ssh_key": "Chiave SSH", "txt_generator_ssh_generating": "Generazione chiave SSH…", "txt_generator_ssh_error": "Questo browser non supporta la generazione di chiavi SSH.", "txt_generator_copy_public_key": "Copia chiave pubblica", "txt_generator_ssh_security_note": "La coppia viene generata localmente e mai inviata. Salva ora la chiave privata; NodeWarden non la conserva.", "txt_generator_public_key": "Chiave pubblica", "txt_generator_private_key": "Chiave privata — mantienila segreta", "txt_generator_ssh_algorithm": "Algoritmo", "txt_generator_key_length": "Lunghezza chiave", "txt_generator_ssh_comment": "Commento chiave pubblica", "txt_generator_ssh_rsa_description": "RSA è ampiamente compatibile. Usa almeno 2048 bit; 4096 sono consigliati.", "txt_generator_ssh_ed25519_description": "Ed25519 è veloce, compatto e consigliato per client SSH moderni.",
|
||||
"txt_generator_email_alias": "Alias email", "txt_generator_email_type": "Tipo di alias", "txt_generator_email_required_hint": "Inserisci l’indirizzo email o il dominio per generare un alias.", "txt_generator_email_description": "I dati restano su questo dispositivo e servono solo a comporre l’alias.", "txt_generator_long_word_username_description": "Combina una o più parole lunghe in un nome utente facile da ricordare.",
|
||||
"nav_backup_strategy": "Backup su Cloud",
|
||||
"nav_import_export": "Importa ed Esporta",
|
||||
"nav_group_data_backup": "Dati e Backup",
|
||||
|
||||
@@ -9,6 +9,10 @@ const ru: Record<string, string> = {
|
||||
"nav_vault_items": "Хранилище",
|
||||
"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": "Добавить число",
|
||||
"txt_generated_value": "Созданное значение", "txt_generator_character_count": "Символов: {count}", "txt_generator_pin": "PIN-код", "txt_generator_pin_description": "Создаёт числовой PIN локально с криптографически стойкой случайностью.", "txt_generator_username": "Имя пользователя", "txt_generator_plus_addressed_email": "Email с плюс-адресацией", "txt_generator_catch_all_email": "Catch-all email", "txt_generator_email": "Адрес электронной почты", "txt_generator_domain": "Домен",
|
||||
"txt_generator_word_list": "Список слов", "txt_generator_eff_word_list": "Длинный список EFF", "txt_generator_custom_word_list": "Свой список слов", "txt_generator_custom_words": "Свои слова", "txt_generator_custom_words_placeholder": "Введите не менее двух слов через пробел, запятую или с новой строки",
|
||||
"txt_generator_long_word_username": "Имя из длинных слов", "txt_generator_custom_word": "Своё мнемоническое слово", "txt_generator_subdomain_email": "Email с поддоменом", "txt_generator_ssh_key": "SSH-ключ", "txt_generator_ssh_generating": "Создание SSH-ключа…", "txt_generator_ssh_error": "Этот браузер не поддерживает создание SSH-ключей.", "txt_generator_copy_public_key": "Копировать открытый ключ", "txt_generator_ssh_security_note": "Пара создаётся локально и не отправляется. Сохраните закрытый ключ сейчас; NodeWarden его не хранит.", "txt_generator_public_key": "Открытый ключ", "txt_generator_private_key": "Закрытый ключ — храните в тайне", "txt_generator_ssh_algorithm": "Алгоритм", "txt_generator_key_length": "Длина ключа", "txt_generator_ssh_comment": "Комментарий открытого ключа", "txt_generator_ssh_rsa_description": "RSA широко совместим. Используйте минимум 2048 бит; рекомендуется 4096.", "txt_generator_ssh_ed25519_description": "Ed25519 быстр, компактен и рекомендуется для современных SSH-клиентов.",
|
||||
"txt_generator_email_alias": "Почтовый псевдоним", "txt_generator_email_type": "Тип псевдонима", "txt_generator_email_required_hint": "Введите адрес электронной почты или домен, чтобы создать псевдоним.", "txt_generator_email_description": "Введённые данные остаются на устройстве и используются только для создания псевдонима.", "txt_generator_long_word_username_description": "Объедините одно или несколько длинных слов в запоминающееся имя пользователя.",
|
||||
"nav_backup_strategy": "Облачное резервное копирование",
|
||||
"nav_import_export": "Импорт и экспорт",
|
||||
"nav_group_data_backup": "Данные и резервные копии",
|
||||
|
||||
@@ -8,6 +8,10 @@ const sv: Record<string, string> = {
|
||||
"nav_vault_items": "Valv",
|
||||
"nav_sends": "Skickat",
|
||||
"nav_generator": "Generator", "txt_password_generator": "Lösenordsgenerator", "txt_password_generator_description": "Skapa ett starkt och unikt lösenord lokalt på den här enheten.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Lösenfras", "txt_generated_password": "Genererat lösenord", "txt_password_strength": "Styrka", "txt_password_strength_weak": "Svagt", "txt_password_strength_fair": "Medel", "txt_password_strength_good": "Bra", "txt_password_strength_strong": "Starkt", "txt_generator_security_note": "Generering sker lokalt. Ditt lösenord skickas aldrig till servern.", "txt_generator_length": "Längd", "txt_generator_character_types": "Teckentyper", "txt_generator_uppercase": "Versaler (A-Z)", "txt_generator_lowercase": "Gemener (a-z)", "txt_generator_numbers": "Siffror (0-9)", "txt_generator_special": "Specialtecken (!@#$%^&*)", "txt_generator_minimum": "Minst", "txt_generator_avoid_ambiguous": "Undvik tvetydiga tecken", "txt_generator_words": "Antal ord", "txt_generator_separator": "Ordavgränsare", "txt_generator_capitalize": "Stor begynnelsebokstav", "txt_generator_include_number": "Inkludera en siffra",
|
||||
"txt_generated_value": "Genererat värde", "txt_generator_character_count": "{count} tecken", "txt_generator_pin": "PIN-kod", "txt_generator_pin_description": "Skapar en numerisk PIN lokalt med kryptografiskt säker slumpning.", "txt_generator_username": "Användarnamn", "txt_generator_plus_addressed_email": "Plusadresserad e-post", "txt_generator_catch_all_email": "Catch-all-e-post", "txt_generator_email": "E-postadress", "txt_generator_domain": "Domän",
|
||||
"txt_generator_word_list": "Ordlista", "txt_generator_eff_word_list": "Lång EFF-ordlista", "txt_generator_custom_word_list": "Egen ordlista", "txt_generator_custom_words": "Egna ord", "txt_generator_custom_words_placeholder": "Ange minst två ord åtskilda med blanksteg, kommatecken eller rader",
|
||||
"txt_generator_long_word_username": "Användarnamn med långa ord", "txt_generator_custom_word": "Eget minnesord", "txt_generator_subdomain_email": "E-post med underdomän", "txt_generator_ssh_key": "SSH-nyckel", "txt_generator_ssh_generating": "Skapar SSH-nyckel…", "txt_generator_ssh_error": "Webbläsaren stöder inte skapande av SSH-nycklar.", "txt_generator_copy_public_key": "Kopiera offentlig nyckel", "txt_generator_ssh_security_note": "Nyckelparet skapas lokalt och skickas aldrig. Spara den privata nyckeln nu; NodeWarden behåller den inte.", "txt_generator_public_key": "Offentlig nyckel", "txt_generator_private_key": "Privat nyckel — håll den hemlig", "txt_generator_ssh_algorithm": "Algoritm", "txt_generator_key_length": "Nyckellängd", "txt_generator_ssh_comment": "Kommentar för offentlig nyckel", "txt_generator_ssh_rsa_description": "RSA har bred kompatibilitet. Använd minst 2048 bitar; 4096 rekommenderas.", "txt_generator_ssh_ed25519_description": "Ed25519 är snabb, kompakt och rekommenderas för moderna SSH-klienter.",
|
||||
"txt_generator_email_alias": "E-postalias", "txt_generator_email_type": "Aliastyp", "txt_generator_email_required_hint": "Ange din e-postadress eller domän för att skapa ett alias.", "txt_generator_email_description": "Inmatningen stannar på enheten och används endast för att skapa aliaset.", "txt_generator_long_word_username_description": "Kombinera ett eller flera långa ord till ett minnesvärt användarnamn.",
|
||||
"nav_backup_strategy": "Molnsäkerhetskopiering",
|
||||
"nav_import_export": "Importera och Exportera",
|
||||
"nav_group_data_backup": "Data och Säkerhetskopiering",
|
||||
|
||||
@@ -11,6 +11,10 @@ const zhCN: Record<string, string> = {
|
||||
"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": "包含数字",
|
||||
"txt_generated_value": "已生成内容", "txt_generator_character_count": "{count} 个字符", "txt_generator_pin": "PIN 码", "txt_generator_pin_description": "使用密码学安全随机数在本地生成纯数字 PIN 码。", "txt_generator_username": "用户名", "txt_generator_plus_addressed_email": "加号别名邮箱", "txt_generator_catch_all_email": "全域接收邮箱", "txt_generator_email": "邮箱地址", "txt_generator_domain": "域名",
|
||||
"txt_generator_word_list": "词表", "txt_generator_eff_word_list": "EFF 长词表", "txt_generator_custom_word_list": "自定义词表", "txt_generator_custom_words": "自定义单词", "txt_generator_custom_words_placeholder": "输入至少两个单词,用空格、逗号或换行分隔",
|
||||
"txt_generator_long_word_username": "长单词用户名", "txt_generator_custom_word": "自定义助记词", "txt_generator_subdomain_email": "子域电子邮件", "txt_generator_ssh_key": "SSH 密钥", "txt_generator_ssh_generating": "正在生成 SSH 密钥…", "txt_generator_ssh_error": "此浏览器不支持 SSH 密钥生成。", "txt_generator_copy_public_key": "复制公钥", "txt_generator_ssh_security_note": "密钥对仅在本地生成,不会发送到服务器。请立即保存私钥,NodeWarden 不会保留它。", "txt_generator_public_key": "公钥", "txt_generator_private_key": "私钥 — 请保密", "txt_generator_ssh_algorithm": "算法", "txt_generator_key_length": "密钥长度", "txt_generator_ssh_comment": "公钥注释", "txt_generator_ssh_rsa_description": "RSA 兼容性广泛。请至少使用 2048 位,推荐 4096 位。", "txt_generator_ssh_ed25519_description": "Ed25519 快速且紧凑,推荐用于现代 SSH 客户端。",
|
||||
"txt_generator_email_alias": "邮箱别名", "txt_generator_email_type": "邮箱别名类型", "txt_generator_email_required_hint": "输入邮箱地址或域名后,即可生成别名。", "txt_generator_email_description": "输入内容只保留在此设备上,仅用于组合邮箱别名。", "txt_generator_long_word_username_description": "将一个或多个长单词组合成易记的用户名。",
|
||||
"nav_backup_strategy": "云端备份",
|
||||
"nav_import_export": "导入导出",
|
||||
"nav_group_data_backup": "数据与备份",
|
||||
|
||||
@@ -11,6 +11,10 @@ const zhTW: Record<string, string> = {
|
||||
"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": "包含數字",
|
||||
"txt_generated_value": "已產生內容", "txt_generator_character_count": "{count} 個字元", "txt_generator_pin": "PIN 碼", "txt_generator_pin_description": "使用密碼學安全亂數在本機產生純數字 PIN 碼。", "txt_generator_username": "使用者名稱", "txt_generator_plus_addressed_email": "加號別名信箱", "txt_generator_catch_all_email": "全域接收信箱", "txt_generator_email": "電子郵件地址", "txt_generator_domain": "網域",
|
||||
"txt_generator_word_list": "詞表", "txt_generator_eff_word_list": "EFF 長詞表", "txt_generator_custom_word_list": "自訂詞表", "txt_generator_custom_words": "自訂單字", "txt_generator_custom_words_placeholder": "輸入至少兩個單字,以空格、逗號或換行分隔",
|
||||
"txt_generator_long_word_username": "長單字使用者名稱", "txt_generator_custom_word": "自訂助記詞", "txt_generator_subdomain_email": "子網域電子郵件", "txt_generator_ssh_key": "SSH 金鑰", "txt_generator_ssh_generating": "正在產生 SSH 金鑰…", "txt_generator_ssh_error": "此瀏覽器不支援 SSH 金鑰產生。", "txt_generator_copy_public_key": "複製公鑰", "txt_generator_ssh_security_note": "金鑰組只在本機產生,不會傳送到伺服器。請立即儲存私鑰,NodeWarden 不會保留它。", "txt_generator_public_key": "公鑰", "txt_generator_private_key": "私鑰 — 請保密", "txt_generator_ssh_algorithm": "演算法", "txt_generator_key_length": "金鑰長度", "txt_generator_ssh_comment": "公鑰註解", "txt_generator_ssh_rsa_description": "RSA 相容性廣泛。請至少使用 2048 位元,建議 4096 位元。", "txt_generator_ssh_ed25519_description": "Ed25519 快速且精簡,建議用於現代 SSH 用戶端。",
|
||||
"txt_generator_email_alias": "信箱別名", "txt_generator_email_type": "信箱別名類型", "txt_generator_email_required_hint": "輸入電子郵件地址或網域後,即可產生別名。", "txt_generator_email_description": "輸入內容只保留在此裝置上,僅用於組合信箱別名。", "txt_generator_long_word_username_description": "將一個或多個長單字組合成容易記住的使用者名稱。",
|
||||
"nav_backup_strategy": "雲端備份",
|
||||
"nav_import_export": "導入導出",
|
||||
"nav_group_data_backup": "資料與備份",
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||
|
||||
export type GeneratorMode = 'password' | 'passphrase' | 'pin' | 'username' | 'email' | 'sshKey';
|
||||
export type EmailMode = 'plusAddressed' | 'catchAll' | 'subdomain';
|
||||
|
||||
export interface PasswordOptions {
|
||||
length: number;
|
||||
uppercase: boolean;
|
||||
lowercase: boolean;
|
||||
numbers: boolean;
|
||||
special: boolean;
|
||||
minUppercase: number;
|
||||
minLowercase: number;
|
||||
minNumbers: number;
|
||||
minSpecial: number;
|
||||
avoidAmbiguous: boolean;
|
||||
}
|
||||
|
||||
export interface PassphraseOptions {
|
||||
words: number;
|
||||
separator: string;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
wordList: 'eff' | 'custom';
|
||||
customWords: string;
|
||||
}
|
||||
|
||||
export interface PinOptions { length: number }
|
||||
|
||||
export interface UsernameOptions {
|
||||
words: number;
|
||||
delimiter: string;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
customWord: string;
|
||||
wordList: 'eff' | 'custom';
|
||||
customWords: string;
|
||||
}
|
||||
|
||||
export interface EmailOptions {
|
||||
type: EmailMode;
|
||||
email: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface SshKeyOptions {
|
||||
type: 'ed25519' | 'rsa';
|
||||
rsaLength: 2048 | 3072 | 4096;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface GeneratorSettings {
|
||||
mode: GeneratorMode;
|
||||
password: PasswordOptions;
|
||||
passphrase: PassphraseOptions;
|
||||
pin: PinOptions;
|
||||
username: UsernameOptions;
|
||||
email: EmailOptions;
|
||||
sshKey: SshKeyOptions;
|
||||
}
|
||||
|
||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const DIGITS = '0123456789';
|
||||
const SPECIAL = '!@#$%^&*_-+=:;,.?~';
|
||||
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1', '|']);
|
||||
|
||||
export const defaultGeneratorSettings: GeneratorSettings = {
|
||||
mode: 'password',
|
||||
password: {
|
||||
length: 16,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
minUppercase: 1,
|
||||
minLowercase: 1,
|
||||
minNumbers: 1,
|
||||
minSpecial: 1,
|
||||
avoidAmbiguous: false,
|
||||
},
|
||||
passphrase: { words: 6, separator: '-', capitalize: false, includeNumber: false, wordList: 'eff', customWords: '' },
|
||||
pin: { length: 6 },
|
||||
username: { words: 2, delimiter: '', capitalize: true, includeNumber: true, customWord: '', wordList: 'eff', customWords: '' },
|
||||
email: { type: 'plusAddressed', email: '', domain: '' },
|
||||
sshKey: { type: 'ed25519', rsaLength: 4096, comment: '' },
|
||||
};
|
||||
|
||||
export function clampInteger(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;
|
||||
}
|
||||
|
||||
export function normalizeGeneratorSettings(value: unknown): GeneratorSettings {
|
||||
const stored = value && typeof value === 'object' ? value as Partial<GeneratorSettings> : {};
|
||||
const password: Partial<PasswordOptions> = stored.password && typeof stored.password === 'object' ? stored.password : {};
|
||||
const passphrase: Partial<PassphraseOptions> = stored.passphrase && typeof stored.passphrase === 'object' ? stored.passphrase : {};
|
||||
const pin: Partial<PinOptions> = stored.pin && typeof stored.pin === 'object' ? stored.pin : {};
|
||||
const username: Partial<UsernameOptions> = stored.username && typeof stored.username === 'object' ? stored.username : {};
|
||||
const legacyUsername = stored.username && typeof stored.username === 'object' ? stored.username as Partial<UsernameOptions> & Partial<EmailOptions> : {};
|
||||
const email: Partial<EmailOptions> = stored.email && typeof stored.email === 'object' ? stored.email : legacyUsername;
|
||||
const sshKey: Partial<SshKeyOptions> = stored.sshKey && typeof stored.sshKey === 'object' ? stored.sshKey : {};
|
||||
const modes: GeneratorMode[] = ['password', 'passphrase', 'pin', 'username', 'email', 'sshKey'];
|
||||
const emailModes: EmailMode[] = ['plusAddressed', 'catchAll', 'subdomain'];
|
||||
const rsaLengths: SshKeyOptions['rsaLength'][] = [2048, 3072, 4096];
|
||||
const mode = stored.mode === 'username' && emailModes.includes(legacyUsername.type as EmailMode)
|
||||
? 'email'
|
||||
: modes.includes(stored.mode as GeneratorMode) ? stored.mode as GeneratorMode : defaultGeneratorSettings.mode;
|
||||
|
||||
return {
|
||||
mode,
|
||||
password: {
|
||||
...defaultGeneratorSettings.password,
|
||||
...password,
|
||||
length: clampInteger(password.length, 5, 128, defaultGeneratorSettings.password.length),
|
||||
minUppercase: clampInteger(password.minUppercase, 0, 9, defaultGeneratorSettings.password.minUppercase),
|
||||
minLowercase: clampInteger(password.minLowercase, 0, 9, defaultGeneratorSettings.password.minLowercase),
|
||||
minNumbers: clampInteger(password.minNumbers, 0, 9, defaultGeneratorSettings.password.minNumbers),
|
||||
minSpecial: clampInteger(password.minSpecial, 0, 9, defaultGeneratorSettings.password.minSpecial),
|
||||
},
|
||||
passphrase: {
|
||||
...defaultGeneratorSettings.passphrase,
|
||||
...passphrase,
|
||||
words: clampInteger(passphrase.words, 3, 20, defaultGeneratorSettings.passphrase.words),
|
||||
separator: String(passphrase.separator ?? defaultGeneratorSettings.passphrase.separator).slice(0, 1),
|
||||
wordList: passphrase.wordList === 'custom' ? 'custom' : 'eff',
|
||||
customWords: String(passphrase.customWords ?? '').slice(0, 50_000),
|
||||
},
|
||||
pin: { length: clampInteger(pin.length, 3, 64, defaultGeneratorSettings.pin.length) },
|
||||
username: {
|
||||
...defaultGeneratorSettings.username,
|
||||
...username,
|
||||
words: clampInteger(username.words, 1, 10, defaultGeneratorSettings.username.words),
|
||||
delimiter: String(username.delimiter ?? defaultGeneratorSettings.username.delimiter).slice(0, 8),
|
||||
customWord: String(username.customWord ?? '').trim().slice(0, 128),
|
||||
wordList: username.wordList === 'custom' ? 'custom' : 'eff',
|
||||
customWords: String(username.customWords ?? '').slice(0, 50_000),
|
||||
},
|
||||
email: {
|
||||
type: emailModes.includes(email.type as EmailMode) ? email.type as EmailMode : defaultGeneratorSettings.email.type,
|
||||
email: String(email.email ?? '').trim().slice(0, 254),
|
||||
domain: String(email.domain ?? '').trim().slice(0, 253),
|
||||
},
|
||||
sshKey: {
|
||||
type: sshKey.type === 'rsa' ? 'rsa' : 'ed25519',
|
||||
rsaLength: rsaLengths.includes(sshKey.rsaLength as SshKeyOptions['rsaLength']) ? sshKey.rsaLength as SshKeyOptions['rsaLength'] : defaultGeneratorSettings.sshKey.rsaLength,
|
||||
comment: String(sshKey.comment ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 256),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function randomIndex(length: number): number {
|
||||
if (!Number.isSafeInteger(length) || length < 1) throw new RangeError('Random source must not be empty');
|
||||
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].filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||
}
|
||||
|
||||
export function generatePassword(options: PasswordOptions): string {
|
||||
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: options.minUppercase });
|
||||
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: options.minLowercase });
|
||||
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: LOWERCASE, minimum: 1 });
|
||||
|
||||
const required = sets.reduce((total, set) => total + set.minimum, 0);
|
||||
const length = Math.max(options.length, required, 5);
|
||||
const allCharacters = sets.map((set) => set.chars).join('');
|
||||
const result = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||
while (result.length < length) result.push(pick(allCharacters));
|
||||
return shuffle(result).join('');
|
||||
}
|
||||
|
||||
export function generatePassphrase(options: PassphraseOptions): string {
|
||||
const customWords = [...new Set(options.customWords.split(/[\s,;]+/).map((word) => word.trim()).filter(Boolean))];
|
||||
const wordList = options.wordList === 'custom' && customWords.length >= 2 ? customWords : EFFLongWordList;
|
||||
const words = Array.from({ length: options.words }, () => wordList[randomIndex(wordList.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);
|
||||
}
|
||||
|
||||
export function generatePin(options: PinOptions): string {
|
||||
return Array.from({ length: options.length }, () => pick(DIGITS)).join('');
|
||||
}
|
||||
|
||||
function randomWord(options: Pick<UsernameOptions, 'words' | 'delimiter' | 'capitalize' | 'includeNumber' | 'customWord' | 'wordList' | 'customWords'>): string {
|
||||
const customWords = [...new Set(options.customWords.split(/[\s,;]+/).map((word) => word.trim()).filter(Boolean))];
|
||||
const wordList = options.wordList === 'custom' && customWords.length >= 2 ? customWords : EFFLongWordList;
|
||||
const words = Array.from({ length: options.words }, () => wordList[randomIndex(wordList.length)]);
|
||||
if (options.customWord) words[randomIndex(words.length)] = options.customWord;
|
||||
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) {
|
||||
const digits = options.words === 1 ? 4 : options.words === 2 ? 3 : 2;
|
||||
const minimum = 10 ** (digits - 1);
|
||||
words[randomIndex(words.length)] += String(minimum + randomIndex(9 * minimum));
|
||||
}
|
||||
return words.join(options.delimiter);
|
||||
}
|
||||
|
||||
function normalizeDomain(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].replace(/^@/, '');
|
||||
}
|
||||
|
||||
export function generateUsername(options: UsernameOptions): string {
|
||||
return randomWord(options);
|
||||
}
|
||||
|
||||
function validDomain(value: string): string | null {
|
||||
const domain = normalizeDomain(value);
|
||||
return /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain) ? domain : null;
|
||||
}
|
||||
|
||||
function validEmail(value: string): { local: string; domain: string } | null {
|
||||
const match = value.trim().match(/^([^\s@+]+)(?:\+[^\s@]*)?@([^\s@]+)$/);
|
||||
const domain = match ? validDomain(match[2]) : null;
|
||||
return match && domain ? { local: match[1], domain } : null;
|
||||
}
|
||||
|
||||
export function generateEmail(options: EmailOptions): string {
|
||||
const suffix = randomWord({ ...defaultGeneratorSettings.username, words: 1, capitalize: false, includeNumber: true });
|
||||
if (options.type === 'catchAll') {
|
||||
const domain = validDomain(options.domain);
|
||||
return domain ? `${suffix}@${domain}` : '';
|
||||
}
|
||||
const email = validEmail(options.email);
|
||||
if (!email) return '';
|
||||
return options.type === 'subdomain'
|
||||
? `${email.local}@${suffix}.${email.domain}`
|
||||
: `${email.local}+${suffix}@${email.domain}`;
|
||||
}
|
||||
|
||||
export function generateValue(settings: GeneratorSettings): string {
|
||||
if (settings.mode === 'passphrase') return generatePassphrase(settings.passphrase);
|
||||
if (settings.mode === 'pin') return generatePin(settings.pin);
|
||||
if (settings.mode === 'username') return generateUsername(settings.username);
|
||||
if (settings.mode === 'email') return generateEmail(settings.email);
|
||||
if (settings.mode === 'sshKey') return '';
|
||||
return generatePassword(settings.password);
|
||||
}
|
||||
|
||||
export function estimateStrength(mode: GeneratorMode, value: string, passphraseWordCount?: number): number {
|
||||
if (mode === 'username' || mode === 'email' || mode === 'sshKey') return 0;
|
||||
if (mode === 'pin') return value.length >= 10 ? 4 : value.length >= 8 ? 3 : value.length >= 6 ? 2 : 1;
|
||||
if (mode === 'passphrase') {
|
||||
const words = passphraseWordCount ?? value.split(/[-_. ]/).filter(Boolean).length;
|
||||
return Math.min(4, Math.max(1, Math.floor(words / 2)));
|
||||
}
|
||||
return Math.min(4, Math.max(1, Math.floor(value.length / 5)));
|
||||
}
|
||||
@@ -23,7 +23,7 @@ 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 {
|
||||
function ensurePasswordSecurityState(fingerprint: string): InternalPasswordSecurityState {
|
||||
if (state?.fingerprint !== fingerprint) {
|
||||
state?.controller?.abort();
|
||||
state = createState(fingerprint);
|
||||
@@ -31,6 +31,10 @@ export function getPasswordSecurityState(fingerprint: string): PasswordSecurityS
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
|
||||
return ensurePasswordSecurityState(fingerprint);
|
||||
}
|
||||
|
||||
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
|
||||
return state?.fingerprint === fingerprint ? state : null;
|
||||
}
|
||||
@@ -41,7 +45,7 @@ export function subscribePasswordSecurityState(listener: () => void): () => void
|
||||
}
|
||||
|
||||
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
|
||||
const current = getPasswordSecurityState(fingerprint);
|
||||
const current = ensurePasswordSecurityState(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;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SshKeyOptions } from '@/lib/password-generator';
|
||||
|
||||
export interface GeneratedSshKey {
|
||||
type: 'ED25519' | 'RSA';
|
||||
bits: number;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function concat(...chunks: Uint8Array[]): Uint8Array {
|
||||
const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function uint32(value: number): Uint8Array {
|
||||
const output = new Uint8Array(4);
|
||||
new DataView(output.buffer).setUint32(0, value, false);
|
||||
return output;
|
||||
}
|
||||
|
||||
function sshString(value: string | Uint8Array): Uint8Array {
|
||||
const bytes = typeof value === 'string' ? encoder.encode(value) : value;
|
||||
return concat(uint32(bytes.length), bytes);
|
||||
}
|
||||
|
||||
function base64UrlBytes(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const decoded = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='));
|
||||
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function base64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function mpint(value: Uint8Array): Uint8Array {
|
||||
let offset = 0;
|
||||
while (offset < value.length - 1 && value[offset] === 0) offset += 1;
|
||||
let bytes = value.subarray(offset);
|
||||
if (bytes[0] & 0x80) bytes = concat(new Uint8Array([0]), bytes);
|
||||
return sshString(bytes);
|
||||
}
|
||||
|
||||
function openSshPrivateKey(publicBlob: Uint8Array, fields: Uint8Array[]): string {
|
||||
const check = new Uint32Array(1);
|
||||
crypto.getRandomValues(check);
|
||||
let privateBlock = concat(uint32(check[0]), uint32(check[0]), ...fields, sshString(''));
|
||||
const paddingLength = 8 - (privateBlock.length % 8);
|
||||
privateBlock = concat(privateBlock, Uint8Array.from({ length: paddingLength }, (_, index) => index + 1));
|
||||
const envelope = concat(
|
||||
encoder.encode('openssh-key-v1\0'),
|
||||
sshString('none'),
|
||||
sshString('none'),
|
||||
sshString(new Uint8Array()),
|
||||
uint32(1),
|
||||
sshString(publicBlob),
|
||||
sshString(privateBlock),
|
||||
);
|
||||
const body = base64(envelope).match(/.{1,70}/g)?.join('\n') || '';
|
||||
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
}
|
||||
|
||||
async function fingerprint(publicBlob: Uint8Array): Promise<string> {
|
||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', Uint8Array.from(publicBlob).buffer));
|
||||
return `SHA256:${base64(digest).replace(/=+$/, '')}`;
|
||||
}
|
||||
|
||||
function required(jwk: JsonWebKey, property: keyof JsonWebKey): Uint8Array {
|
||||
const value = jwk[property];
|
||||
if (typeof value !== 'string' || !value) throw new Error(`The generated key is missing ${String(property)}`);
|
||||
return base64UrlBytes(value);
|
||||
}
|
||||
|
||||
async function generateEd25519(comment: string): Promise<GeneratedSshKey> {
|
||||
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']) as CryptoKeyPair;
|
||||
const [privateJwk, publicJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', pair.privateKey),
|
||||
crypto.subtle.exportKey('jwk', pair.publicKey),
|
||||
]);
|
||||
const publicBytes = required(publicJwk, 'x');
|
||||
const seed = required(privateJwk, 'd');
|
||||
const publicBlob = concat(sshString('ssh-ed25519'), sshString(publicBytes));
|
||||
const privateKey = openSshPrivateKey(publicBlob, [
|
||||
sshString('ssh-ed25519'),
|
||||
sshString(publicBytes),
|
||||
sshString(concat(seed, publicBytes)),
|
||||
]);
|
||||
return {
|
||||
type: 'ED25519',
|
||||
bits: 256,
|
||||
publicKey: `ssh-ed25519 ${base64(publicBlob)}${comment ? ` ${comment}` : ''}`,
|
||||
privateKey,
|
||||
fingerprint: await fingerprint(publicBlob),
|
||||
};
|
||||
}
|
||||
|
||||
async function generateRsa(length: SshKeyOptions['rsaLength'], comment: string): Promise<GeneratedSshKey> {
|
||||
const pair = await crypto.subtle.generateKey({
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: length,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
}, true, ['sign', 'verify']) as CryptoKeyPair;
|
||||
const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
||||
const n = required(privateJwk, 'n');
|
||||
const e = required(privateJwk, 'e');
|
||||
const d = required(privateJwk, 'd');
|
||||
const qi = required(privateJwk, 'qi');
|
||||
const p = required(privateJwk, 'p');
|
||||
const q = required(privateJwk, 'q');
|
||||
const publicBlob = concat(sshString('ssh-rsa'), mpint(e), mpint(n));
|
||||
const privateKey = openSshPrivateKey(publicBlob, [
|
||||
sshString('ssh-rsa'),
|
||||
mpint(n), mpint(e), mpint(d), mpint(qi), mpint(p), mpint(q),
|
||||
]);
|
||||
return {
|
||||
type: 'RSA',
|
||||
bits: length,
|
||||
publicKey: `ssh-rsa ${base64(publicBlob)}${comment ? ` ${comment}` : ''}`,
|
||||
privateKey,
|
||||
fingerprint: await fingerprint(publicBlob),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSshKey(options: SshKeyOptions): Promise<GeneratedSshKey> {
|
||||
if (!crypto?.subtle) throw new Error('Web Crypto is unavailable');
|
||||
const comment = options.comment.replace(/[\r\n]+/g, ' ').trim();
|
||||
return options.type === 'rsa' ? generateRsa(options.rsaLength, comment) : generateEd25519(comment);
|
||||
}
|
||||
@@ -369,7 +369,7 @@
|
||||
.not-found-code {
|
||||
@apply rounded-full px-3 py-1 text-sm font-extrabold;
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.not-found-copy {
|
||||
@@ -574,7 +574,7 @@
|
||||
|
||||
.jwt-inline-link {
|
||||
@apply font-bold no-underline;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.jwt-inline-link:hover {
|
||||
@@ -613,7 +613,7 @@
|
||||
|
||||
.standalone-footer a {
|
||||
@apply font-bold no-underline;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.standalone-footer a:hover {
|
||||
@@ -622,5 +622,5 @@
|
||||
|
||||
.standalone-version {
|
||||
@apply font-bold;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
@@ -220,7 +220,7 @@
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .card-brand-icon {
|
||||
color: #bfdbfe;
|
||||
color: var(--primary-strong);
|
||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
.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-value.empty { display: grid; place-items: center; color: var(--muted); font-family: inherit; font-size: 14px; font-weight: 500; text-align: center; user-select: none; }
|
||||
.generator-mode-tabs { flex-wrap: wrap; }
|
||||
.generator-meta-row { display: grid; grid-template-columns: minmax(90px, 1fr) auto auto; align-items: center; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
|
||||
.generator-meta-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.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); }
|
||||
@@ -20,6 +22,11 @@
|
||||
.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-select-field, .generator-text-field { display: grid; gap: 8px; margin-bottom: 16px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-select-field .input, .generator-text-field .input { width: 100%; }.generator-options-note { margin: 14px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.generator-word-list-input { min-height: 118px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; line-height: 1.5; }
|
||||
.generator-key-status { display: flex; min-height: 180px; margin: 18px 0 10px; align-items: center; justify-content: center; gap: 10px; border: 1px solid var(--line); border-radius: 16px; color: var(--muted-strong); }.generator-key-status.error { padding: 18px; color: var(--danger); text-align: center; }
|
||||
.generator-spinner { animation: generator-spin 900ms linear infinite; }.generator-key-output { display: grid; gap: 12px; margin: 18px 0 10px; }.generator-key-summary, .generator-key-field, .generator-private-key { border: 1px solid var(--line); border-radius: 14px; background: color-mix(in srgb, var(--primary) 4%, var(--panel)); }.generator-key-summary { display: grid; gap: 6px; padding: 14px; }.generator-key-summary code { overflow-wrap: anywhere; color: var(--muted-strong); font-size: 12px; }.generator-key-field { display: grid; gap: 9px; padding: 14px; }.generator-key-field > span { font-size: 13px; font-weight: 700; }.generator-key-field > code, .generator-private-key > code { display: block; max-height: 150px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; line-height: 1.45; user-select: all; }.generator-key-field-actions { display: flex; flex-wrap: wrap; gap: 8px; }.generator-key-field-actions .btn { display: inline-flex; gap: 6px; }.generator-private-key { padding: 0 14px 14px; }.generator-private-key summary { padding: 14px 0; cursor: pointer; font-size: 13px; font-weight: 700; }.generator-private-key .generator-key-field-actions { margin-top: 10px; }
|
||||
@keyframes generator-spin { to { transform: rotate(360deg); } }
|
||||
.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; } }
|
||||
@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-meta-row { grid-template-columns: minmax(60px, 1fr) auto; }.generator-meta-row > span:last-child { grid-column: 1 / -1; }.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; }.generator-spinner { animation: none; } }
|
||||
@@ -30,6 +30,14 @@
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* The detail panel is only as wide as the viewport minus the app sidebar and the
|
||||
two backup sidebars (~858px of chrome), so viewport breakpoints cannot tell when
|
||||
the remote backup list no longer fits. Query the panel's own width instead. */
|
||||
.backup-detail-panel {
|
||||
container-type: inline-size;
|
||||
container-name: backup-detail;
|
||||
}
|
||||
|
||||
.backup-actions-stack {
|
||||
@apply grid gap-2.5;
|
||||
}
|
||||
@@ -99,7 +107,7 @@
|
||||
@apply inline-flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-full p-0 text-[13px] font-extrabold leading-none;
|
||||
border: 1px solid #bfd1f3;
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.backup-help-trigger:hover,
|
||||
@@ -194,7 +202,7 @@
|
||||
}
|
||||
|
||||
.backup-recommendation-step a {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
@@ -316,14 +324,14 @@
|
||||
}
|
||||
|
||||
.backup-interval-preset:hover:not(:disabled) {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.backup-interval-preset.active {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -371,13 +379,21 @@
|
||||
}
|
||||
|
||||
.backup-browser-list {
|
||||
@apply overflow-hidden rounded-lg border bg-white;
|
||||
@apply rounded-lg border bg-white;
|
||||
border: 1px solid var(--line);
|
||||
/* Never clip silently: if a row still overflows (long locale labels, different
|
||||
font metrics), scroll instead of hiding the action buttons. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.backup-browser-head,
|
||||
.backup-browser-row {
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
@apply grid items-center gap-3 px-3 py-2 text-[11px] font-bold uppercase tracking-[0.08em];
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
@@ -404,7 +420,6 @@
|
||||
|
||||
.backup-browser-row {
|
||||
@apply grid items-center gap-3 px-3 py-2;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
@@ -434,7 +449,42 @@
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
/* Wrap instead of clipping: three non-shrinking buttons overflow the 220px
|
||||
actions track once the panel is narrow, hiding Delete entirely. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-actions .btn.small {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Must stay after every base .backup-browser-* rule above: container queries add
|
||||
no specificity, so source order decides which declaration wins.
|
||||
The four tracks need ~702px, plus headroom for the widest locale label
|
||||
(fr "Restauration en cours..." pushes a button past the 220px actions track).
|
||||
Below that, stack the row into a card and drop the column header. */
|
||||
@container backup-detail (max-width: 820px) {
|
||||
.backup-browser-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.backup-browser-empty {
|
||||
@@ -1786,7 +1836,7 @@
|
||||
}
|
||||
|
||||
.restore-progress-item.active {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.restore-progress-item.done {
|
||||
@@ -1799,7 +1849,7 @@
|
||||
}
|
||||
|
||||
.restore-progress-item.active .restore-progress-dot {
|
||||
background: #1d4ed8;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.restore-progress-item.done .restore-progress-dot {
|
||||
@@ -1871,7 +1921,7 @@
|
||||
.authorized-device-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #2563eb;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.authorized-devices-table td:first-child {
|
||||
@@ -1879,7 +1929,9 @@
|
||||
}
|
||||
|
||||
.authorized-devices-actions {
|
||||
flex-wrap: nowrap;
|
||||
/* Wrap instead of clipping: four non-shrinking buttons overflow the
|
||||
fixed 26% actions column at common widths, hiding Delete entirely. */
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,29 +41,10 @@
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.import-export-panels,
|
||||
.backup-browser-row {
|
||||
.import-export-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1248,29 +1229,10 @@
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.backup-browser-row,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backup-grid {
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
.vault-grid {
|
||||
@apply grid h-full min-h-0 gap-3 p-0.5;
|
||||
grid-template-columns: 270px minmax(400px, 36%) minmax(400px, 1fr);
|
||||
/* The list and detail panes already scroll internally (overflow: auto), which
|
||||
zeroes their automatic minimum size. Keeping the track minimums at 0 lets the
|
||||
grid fit the content area at any width instead of overflowing the page; wide
|
||||
viewports are unaffected because 36%/1fr were never binding there. */
|
||||
grid-template-columns: 270px minmax(0, 36%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
@@ -47,7 +51,7 @@
|
||||
}
|
||||
|
||||
.folder-add-btn:hover {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@@ -161,7 +165,7 @@
|
||||
}
|
||||
|
||||
.folder-sort-btn:hover {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
background: #dbeafe;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
@@ -230,7 +234,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.list-head {
|
||||
@apply mb-1.5 flex items-center gap-2;
|
||||
@apply mb-1.5 flex flex-wrap items-center gap-2;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
@@ -860,7 +864,9 @@ select.input.duplicate-mode-toolbar-select {
|
||||
|
||||
.kv-row {
|
||||
@apply grid items-center gap-2.5 py-2.5;
|
||||
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto;
|
||||
/* Floor the value column so the auto-sized actions column wraps its
|
||||
buttons instead of collapsing the value to 0 width. */
|
||||
grid-template-columns: minmax(0px, 80px) minmax(min(35%, 140px), 1fr) auto;
|
||||
border-bottom: 1px solid rgba(154, 172, 205, 0.22);
|
||||
}
|
||||
|
||||
@@ -962,7 +968,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.totp-ring-progress {
|
||||
stroke: #2563eb;
|
||||
stroke: var(--primary);
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
|
||||
}
|
||||
|
||||
+22
-61
@@ -258,39 +258,12 @@ function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
function resourcePriorityPlugin(isDemo: boolean): Plugin {
|
||||
return {
|
||||
name: 'nodewarden-resource-priority',
|
||||
enforce: 'post' as const,
|
||||
transformIndexHtml(html: string) {
|
||||
if (isDemo || !html.includes('/assets/app-suite-')) return html;
|
||||
|
||||
const scriptMatch = html.match(/^\s*<script type="module" crossorigin src="\/assets\/index-[^"]+\.js"><\/script>\s*$/m);
|
||||
const appSuiteMatch = html.match(/^\s*<link rel="modulepreload" crossorigin href="\/assets\/app-suite-[^"]+\.js">\s*$/m);
|
||||
const stylesheetMatch = html.match(/^\s*<link rel="stylesheet" crossorigin href="\/assets\/index-[^"]+\.css">\s*$/m);
|
||||
|
||||
if (!scriptMatch || !appSuiteMatch || !stylesheetMatch) return html;
|
||||
|
||||
const prioritizedTags = [
|
||||
stylesheetMatch[0].replace('rel="stylesheet"', 'rel="stylesheet" fetchpriority="high"'),
|
||||
appSuiteMatch[0].replace('rel="modulepreload"', 'rel="modulepreload" fetchpriority="high"'),
|
||||
scriptMatch[0].replace('type="module"', 'type="module" fetchpriority="high"'),
|
||||
].join('\n');
|
||||
|
||||
return html
|
||||
.replace(scriptMatch[0], '')
|
||||
.replace(appSuiteMatch[0], '')
|
||||
.replace(stylesheetMatch[0], prioritizedTags);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDemo = mode === 'demo';
|
||||
|
||||
return {
|
||||
root: rootDir,
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
define: {
|
||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||
},
|
||||
@@ -311,41 +284,29 @@ export default defineConfig(({ mode }) => {
|
||||
sourcemap: false,
|
||||
target: 'esnext',
|
||||
chunkSizeWarningLimit: 800,
|
||||
rollupOptions: {
|
||||
treeshake: {
|
||||
preset: 'smallest',
|
||||
rolldownOptions: {
|
||||
checks: {
|
||||
pluginTimings: false,
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const normalized = id.replace(/\\/g, '/');
|
||||
|
||||
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||
if (localeMatch) {
|
||||
if (localeMatch[1] === 'en') return undefined;
|
||||
return `i18n-${localeMatch[1]}`;
|
||||
}
|
||||
|
||||
if (
|
||||
!isDemo &&
|
||||
(
|
||||
normalized.includes('/src/components/VaultPage.tsx') ||
|
||||
normalized.includes('/src/components/ImportPage.tsx') ||
|
||||
normalized.includes('/src/lib/import-') ||
|
||||
normalized.includes('/src/lib/export-formats.ts') ||
|
||||
normalized.includes('/src/components/SendsPage.tsx') ||
|
||||
normalized.includes('/src/components/TotpCodesPage.tsx') ||
|
||||
normalized.includes('/src/components/DomainRulesPage.tsx') ||
|
||||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
|
||||
normalized.includes('/src/components/backup-center/') ||
|
||||
normalized.includes('/src/components/SettingsPage.tsx') ||
|
||||
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
|
||||
normalized.includes('/src/components/AdminPage.tsx')
|
||||
)
|
||||
) {
|
||||
return 'app-suite';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: 'shared',
|
||||
minShareCount: 2,
|
||||
minSize: 50 * 1024,
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
name(id) {
|
||||
const normalized = id.replace(/\\/g, '/');
|
||||
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||
return localeMatch && localeMatch[1] !== 'en' ? `i18n-${localeMatch[1]}` : null;
|
||||
},
|
||||
test: /[\\/]src[\\/]lib[\\/]i18n[\\/]locales[\\/]/,
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user