mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
7
Commits
e424162227
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652952379b | ||
|
|
34fd2f0259 | ||
|
|
df493b4697 | ||
|
|
f6403b8cab | ||
|
|
a0128908e1 | ||
|
|
8e5d9e294b | ||
|
|
6ffdf05dc6 |
Generated
+350
-321
File diff suppressed because it is too large
Load Diff
+15
-13
@@ -15,6 +15,7 @@
|
||||
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||
"i18n": "node scripts/i18n-validate.cjs",
|
||||
"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: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",
|
||||
@@ -50,30 +51,31 @@
|
||||
"undici": ">=7.28.0",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"esbuild": ">=0.28.1",
|
||||
"ws": "8.21.0"
|
||||
"ws": "8.21.0",
|
||||
"sharp": "0.35.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
"@preact/preset-vite": "^2.10.6",
|
||||
"@types/node": "^26.1.1",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"opencc-js": "^1.4.1",
|
||||
"postcss": "^8.5.22",
|
||||
"@preact/preset-vite": "^2.10.5",
|
||||
"@types/node": "^26.0.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"opencc-js": "^1.3.2",
|
||||
"postcss": "^8.5.23",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.23.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.5",
|
||||
"wrangler": "^4.113.0"
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@zip.js/zip.js": "^2.8.33",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"fflate": "^0.8.3",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.25.0",
|
||||
"preact": "^10.29.7",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"wouter": "^3.10.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildConfigResponse } from '../src/config-response';
|
||||
|
||||
test('config enables the official Bitwarden desktop settings dialog', () => {
|
||||
const body = buildConfigResponse('https://vault.example.test');
|
||||
|
||||
assert.equal(body.featureStates['desktop-ui-settings-dialog'], true);
|
||||
assert.equal(body.environment.vault, 'https://vault.example.test');
|
||||
assert.equal(body.object, 'config');
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
|
||||
function buildIconServiceTemplate(origin: string): string {
|
||||
return `${origin}/icons/{}/icon.png`;
|
||||
}
|
||||
|
||||
function buildIconServiceCsp(origin: string): string {
|
||||
return `img-src 'self' data: ${origin}`;
|
||||
}
|
||||
|
||||
export function buildConfigResponse(origin: string) {
|
||||
const fillAssistBase = `${origin}/fill-assist/`;
|
||||
return {
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
server: null,
|
||||
environment: {
|
||||
cloudRegion: 'self-hosted',
|
||||
vault: origin,
|
||||
api: origin + '/api',
|
||||
identity: origin + '/identity',
|
||||
notifications: origin + '/notifications',
|
||||
icons: origin,
|
||||
sso: '',
|
||||
fillAssistRules: fillAssistBase,
|
||||
},
|
||||
push: {
|
||||
pushTechnology: 0,
|
||||
vapidPublicKey: null,
|
||||
},
|
||||
communication: null,
|
||||
settings: {
|
||||
disableUserRegistration: false,
|
||||
suppressOnboardingInterstitials: false,
|
||||
},
|
||||
_icon_service_url: buildIconServiceTemplate(origin),
|
||||
_icon_service_csp: buildIconServiceCsp(origin),
|
||||
featureStates: {
|
||||
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
||||
'desktop-ui-settings-dialog': true,
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'fill-assist-targeting-rules': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'pm-19148-innovation-archive': true,
|
||||
'pm-4516-devices-add-last-activity-date': true,
|
||||
'pm-30529-webauthn-related-origins': true,
|
||||
'unauth-ui-refresh': true,
|
||||
'web-push': false,
|
||||
},
|
||||
object: 'config',
|
||||
};
|
||||
}
|
||||
@@ -156,7 +156,9 @@
|
||||
compatibility: {
|
||||
// Single source of truth for /config.version and /api/version.
|
||||
// /config.version 与 /api/version 的统一版本号来源。
|
||||
bitwardenServerVersion: '2026.4.1',
|
||||
// Vaultwarden 1.37.0 advertises 2026.6.0 after aligning its API response
|
||||
// with the response contract required by Bitwarden 2026.7.x clients.
|
||||
bitwardenServerVersion: '2026.6.0',
|
||||
// Official 2026.4.x clients need this flag to receive and use cipher.key.
|
||||
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
||||
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
||||
|
||||
+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(
|
||||
|
||||
@@ -206,6 +206,16 @@ function buildPreloginResponse(
|
||||
kdfIterations,
|
||||
kdfMemory,
|
||||
kdfParallelism,
|
||||
// Current official servers expose the consolidated KDF model alongside
|
||||
// the legacy flat fields. Keep both shapes while clients migrate.
|
||||
kdfSettings: {
|
||||
kdfType,
|
||||
iterations: kdfIterations,
|
||||
memory: kdfMemory,
|
||||
parallelism: kdfParallelism,
|
||||
},
|
||||
salt: null,
|
||||
// Preserve the historic NodeWarden aliases for older integrations.
|
||||
KdfSettings: {
|
||||
KdfType: kdfType,
|
||||
Iterations: kdfIterations,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
+1
-54
@@ -36,6 +36,7 @@ import { jsonResponse, unsupportedResponse } from './utils/response';
|
||||
import { StorageService } from './services/storage';
|
||||
import type { Env } from './types';
|
||||
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||
import { buildConfigResponse } from './config-response';
|
||||
|
||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||
@@ -95,60 +96,6 @@ function handleMissingWebsiteIcon(): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function buildIconServiceBase(origin: string): string {
|
||||
return `${origin}/icons`;
|
||||
}
|
||||
|
||||
function buildIconServiceTemplate(origin: string): string {
|
||||
return `${buildIconServiceBase(origin)}/{}/icon.png`;
|
||||
}
|
||||
|
||||
function buildIconServiceCsp(origin: string): string {
|
||||
return `img-src 'self' data: ${origin}`;
|
||||
}
|
||||
|
||||
function buildConfigResponse(origin: string) {
|
||||
const fillAssistBase = `${origin}/fill-assist/`;
|
||||
return {
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
server: null,
|
||||
environment: {
|
||||
cloudRegion: 'self-hosted',
|
||||
vault: origin,
|
||||
api: origin + '/api',
|
||||
identity: origin + '/identity',
|
||||
notifications: origin + '/notifications',
|
||||
icons: origin,
|
||||
sso: '',
|
||||
fillAssistRules: fillAssistBase,
|
||||
},
|
||||
push: {
|
||||
pushTechnology: 0,
|
||||
vapidPublicKey: null,
|
||||
},
|
||||
communication: null,
|
||||
settings: {
|
||||
disableUserRegistration: false,
|
||||
},
|
||||
_icon_service_url: buildIconServiceTemplate(origin),
|
||||
_icon_service_csp: buildIconServiceCsp(origin),
|
||||
featureStates: {
|
||||
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'fill-assist-targeting-rules': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'pm-19148-innovation-archive': true,
|
||||
'pm-4516-devices-add-last-activity-date': true,
|
||||
'pm-30529-webauthn-related-origins': true,
|
||||
'unauth-ui-refresh': true,
|
||||
'web-push': false,
|
||||
},
|
||||
object: 'config',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIconHost(rawHost: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
|
||||
@@ -17,8 +17,12 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
|
||||
wrappedPrivateKey: user.privateKey,
|
||||
publicKey,
|
||||
signedPublicKey: null,
|
||||
object: 'publicKeyEncryptionKeyPair',
|
||||
Object: 'publicKeyEncryptionKeyPair',
|
||||
},
|
||||
securityState: null,
|
||||
signatureKeyPair: null,
|
||||
object: 'privateKeys',
|
||||
Object: 'privateKeys',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; } }
|
||||
|
||||
+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