Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub e0d70a666f chore(deps): bump the npm-minor-and-patch group across 1 directory with 12 updates
Bumps the npm-minor-and-patch group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.101.2` | `5.101.4` |
| [@zip.js/zip.js](https://github.com/gildas-lormeau/zip.js) | `2.8.26` | `2.8.34` |
| [lucide-preact](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-preact) | `1.22.0` | `1.28.0` |
| [preact](https://github.com/preactjs/preact) | `10.29.3` | `10.29.7` |
| [@preact/preset-vite](https://github.com/preactjs/preset-vite) | `2.10.5` | `2.10.6` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.1` | `26.1.2` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.2` | `10.5.4` |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.3.2` | `1.4.1` |
| [postcss](https://github.com/postcss/postcss) | `8.5.23` | `8.5.25` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.3` | `8.2.0` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.105.0` | `4.116.0` |



Updates `@tanstack/react-query` from 5.101.2 to 5.101.4
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.4/packages/react-query)

Updates `@zip.js/zip.js` from 2.8.26 to 2.8.34
- [Release notes](https://github.com/gildas-lormeau/zip.js/releases)
- [Commits](https://github.com/gildas-lormeau/zip.js/compare/v2.8.26...v2.8.34)

Updates `lucide-preact` from 1.22.0 to 1.28.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.28.0/packages/lucide-preact)

Updates `preact` from 10.29.3 to 10.29.7
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.3...10.29.7)

Updates `@preact/preset-vite` from 2.10.5 to 2.10.6
- [Release notes](https://github.com/preactjs/preset-vite/releases)
- [Changelog](https://github.com/preactjs/preset-vite/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preset-vite/commits)

Updates `@types/node` from 26.0.1 to 26.1.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.5.2 to 10.5.4
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.2...10.5.4)

Updates `opencc-js` from 1.3.2 to 1.4.1
- [Release notes](https://github.com/nk2028/opencc-js/releases)
- [Changelog](https://github.com/nk2028/opencc-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nk2028/opencc-js/compare/v1.3.2...v1.4.1)

Updates `postcss` from 8.5.23 to 8.5.25
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.23...8.5.25)

Updates `tsx` from 4.22.4 to 4.23.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.1)

Updates `vite` from 8.1.3 to 8.2.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite)

Updates `wrangler` from 4.105.0 to 4.116.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.116.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@zip.js/zip.js"
  dependency-version: 2.8.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: lucide-preact
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: preact
  dependency-version: 10.29.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@preact/preset-vite"
  dependency-version: 2.10.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.1.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: opencc-js
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.116.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-02 21:04:59 +00:00
shuaiplus 652952379b fix: stop accepting backup blob credentials from the URL 2026-07-26 16:00:39 +08:00
shuaiplus 34fd2f0259 build: optimize Rolldown chunk splitting 2026-07-25 22:49:02 +08:00
EnvyBackyardandGitHub df493b4697 feat: expand password generator options (#329) 2026-07-25 22:23:02 +08:00
shuaiplus f6403b8cab fix: upgrade PostCSS to patch path traversal vulnerability 2026-07-25 21:44:22 +08:00
shuaiplus a0128908e1 fix: align API responses with Bitwarden 2026.7 clients 2026-07-25 21:25:51 +08:00
shuaiplus 8e5d9e294b fix: update sharp to 0.35.0 2026-07-23 18:28:00 +08:00
23 changed files with 1257 additions and 652 deletions
+445 -389
View File
File diff suppressed because it is too large Load Diff
+14 -13
View File
@@ -51,30 +51,31 @@
"undici": ">=7.28.0", "undici": ">=7.28.0",
"@babel/core": ">=7.29.6", "@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1", "esbuild": ">=0.28.1",
"ws": "8.21.0" "ws": "8.21.0",
"sharp": "0.35.0"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260630.1", "@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.5", "@preact/preset-vite": "^2.10.6",
"@types/node": "^26.0.1", "@types/node": "^26.1.2",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.4",
"opencc-js": "^1.3.2", "opencc-js": "^1.4.1",
"postcss": "^8.5.16", "postcss": "^8.5.25",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tsx": "^4.22.4", "tsx": "^4.23.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.1.3", "vite": "^8.2.0",
"wrangler": "^4.105.0" "wrangler": "^4.116.0"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.4",
"@zip.js/zip.js": "^2.8.26", "@zip.js/zip.js": "^2.8.34",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"jsqr": "1.4.0", "jsqr": "1.4.0",
"lucide-preact": "^1.22.0", "lucide-preact": "^1.28.0",
"preact": "^10.29.3", "preact": "^10.29.7",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"wouter": "^3.10.0" "wouter": "^3.10.0"
} }
+1
View File
@@ -31,6 +31,7 @@ export function buildConfigResponse(origin: string) {
communication: null, communication: null,
settings: { settings: {
disableUserRegistration: false, disableUserRegistration: false,
suppressOnboardingInterstitials: false,
}, },
_icon_service_url: buildIconServiceTemplate(origin), _icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin), _icon_service_csp: buildIconServiceCsp(origin),
+3 -1
View File
@@ -156,7 +156,9 @@
compatibility: { compatibility: {
// Single source of truth for /config.version and /api/version. // Single source of truth for /config.version and /api/version.
// /config.version 与 /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. // 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. // Hiding existing item keys makes item-key encrypted vault data unreadable.
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。 // 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
+8 -13
View File
@@ -1247,19 +1247,14 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try { try {
const url = new URL(request.url); // Read the request body only. Accepting these fields from the query string
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {}; // would put the master-password authentication hash in the URL, where it is
if (request.method === 'POST') { // captured by request logs, browser history and Referer headers.
try { let input: { blobName?: unknown; masterPasswordHash?: unknown };
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>(); try {
} catch { input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
return errorResponse('Backup attachment download payload is invalid', 400); } catch {
} return errorResponse('Backup attachment download payload is invalid', 400);
} else {
input = {
blobName: url.searchParams.get('blobName') || '',
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
};
} }
const verificationError = await requireBackupUserVerification( const verificationError = await requireBackupUserVerification(
+10
View File
@@ -206,6 +206,16 @@ function buildPreloginResponse(
kdfIterations, kdfIterations,
kdfMemory, kdfMemory,
kdfParallelism, 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: { KdfSettings: {
KdfType: kdfType, KdfType: kdfType,
Iterations: kdfIterations, Iterations: kdfIterations,
+17 -2
View File
@@ -14,6 +14,7 @@ import {
handleRunAdminConfiguredBackup, handleRunAdminConfiguredBackup,
handleUpdateAdminBackupSettings, handleUpdateAdminBackupSettings,
} from './handlers/backup'; } from './handlers/backup';
import { errorResponse } from './utils/response';
export async function handleAdminBackupRoute( export async function handleAdminBackupRoute(
request: Request, request: Request,
@@ -26,8 +27,22 @@ export async function handleAdminBackupRoute(
return handleAdminExportBackup(request, env, actorUser); return handleAdminExportBackup(request, env, actorUser);
} }
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) { if (path === '/api/admin/backup/blob') {
return handleDownloadAdminBackupAttachment(request, env, actorUser); // 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') { if (path === '/api/admin/backup/settings') {
+4
View File
@@ -17,8 +17,12 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
wrappedPrivateKey: user.privateKey, wrappedPrivateKey: user.privateKey,
publicKey, publicKey,
signedPublicKey: null, signedPublicKey: null,
object: 'publicKeyEncryptionKeyPair',
Object: 'publicKeyEncryptionKeyPair', Object: 'publicKeyEncryptionKeyPair',
}, },
securityState: null,
signatureKeyPair: null,
object: 'privateKeys',
Object: 'privateKeys', Object: 'privateKeys',
}; };
} }
+240 -169
View File
@@ -1,158 +1,77 @@
import { useEffect, useMemo, useState } from 'preact/hooks'; 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 { copyTextToClipboard } from '@/lib/clipboard';
import { EFFLongWordList } from '@/lib/eff-word-list';
import { t } from '@/lib/i18n'; 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 { function readSettings(): GeneratorSettings {
length: number;
uppercase: boolean;
lowercase: boolean;
numbers: boolean;
special: boolean;
minNumbers: number;
minSpecial: number;
avoidAmbiguous: boolean;
}
interface PassphraseOptions {
words: number;
separator: string;
capitalize: boolean;
includeNumber: boolean;
}
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
const DIGITS = '0123456789';
const SPECIAL = '!@#$%^&*';
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
const defaultPasswordOptions: PasswordOptions = {
length: 14,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
minNumbers: 1,
minSpecial: 1,
avoidAmbiguous: false,
};
const defaultPassphraseOptions: PassphraseOptions = {
words: 6,
separator: '-',
capitalize: false,
includeNumber: false,
};
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
}
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
try { try {
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>; const current = localStorage.getItem(SETTINGS_KEY);
return { if (current) return normalizeGeneratorSettings(JSON.parse(current));
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
password: { // Preserve compatible options for users upgrading from the original generator.
...defaultPasswordOptions, const legacy = JSON.parse(localStorage.getItem('nodewarden.passwordGenerator.settings.v1') || '{}');
...stored.password, return normalizeGeneratorSettings(legacy);
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
},
passphrase: {
...defaultPassphraseOptions,
...stored.passphrase,
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
},
};
} catch { } 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() { export default function PasswordGeneratorPage() {
const initial = useMemo(readSettings, []); const initial = useMemo(readSettings, []);
const [mode, setMode] = useState<GeneratorMode>(initial.mode); const [settings, setSettings] = useState<GeneratorSettings>(initial);
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
const [seed, setSeed] = useState(0); const [seed, setSeed] = useState(0);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [sshKey, setSshKey] = useState<GeneratedSshKey | null>(null);
const [sshKeyError, setSshKeyError] = useState('');
const [sshKeyLoading, setSshKeyLoading] = useState(false);
const generated = useMemo( const generated = useMemo(() => settings.mode === 'sshKey' ? sshKey?.fingerprint || '' : generateValue(settings), [settings, seed, sshKey]);
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)), const strength = useMemo(
[mode, passwordOptions, passphraseOptions, seed] () => 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(() => { useEffect(() => {
try { try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions })); localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch { } catch {
// The generator remains fully usable when browser storage is unavailable. // 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 = () => { const regenerate = () => {
setCopied(false); setCopied(false);
@@ -160,17 +79,49 @@ export default function PasswordGeneratorPage() {
}; };
const copy = async () => { 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); window.setTimeout(() => setCopied(false), 1600);
}; };
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => { const changeMode = (mode: GeneratorMode) => {
setPasswordOptions((current) => ({ ...current, [key]: value })); setSettings((current) => ({ ...current, mode }));
setCopied(false); 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]) => { 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); setCopied(false);
}; };
@@ -178,62 +129,182 @@ export default function PasswordGeneratorPage() {
<section className="generator-page" aria-label={t('txt_password_generator')}> <section className="generator-page" aria-label={t('txt_password_generator')}>
<div className="generator-layout"> <div className="generator-layout">
<section className="generator-output-card" aria-live="polite"> <section className="generator-output-card" aria-live="polite">
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}> <div className="settings-category-tabs generator-mode-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> ['password', 'txt_password'],
</div> ['passphrase', 'txt_passphrase'],
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output> ['pin', 'txt_generator_pin'],
<div className="generator-strength-row"> ['username', 'txt_generator_username'],
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}> ['email', 'txt_generator_email_alias'],
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)} ['sshKey', 'txt_generator_ssh_key'],
</div> ] as const).map(([mode, label]) => (
<span><ShieldCheck size={15} /> {strength.label}</span> <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> </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"> <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-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" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</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> </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>
<section className="generator-options-card" aria-labelledby="generator-options-title"> <section className="generator-options-card" aria-labelledby="generator-options-title">
<h2 id="generator-options-title">{t('txt_options')}</h2> <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)} /> <GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={settings.passphrase.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend> <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>
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} /> {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>}
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} /> <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>
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
</fieldset>
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
</>
) : (
<>
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
<div className="generator-option-group"> <div className="generator-option-group">
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} /> <GeneratorToggle checked={settings.passphrase.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.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
</div> </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> </section>
</div> </div>
</section> </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 }) { 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>; 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 }) { 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 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 ( return (
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}> <div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
<label htmlFor={id}>{props.label}</label> <label htmlFor={id}>{props.label}</label>
+4
View File
@@ -8,6 +8,10 @@ const de: Record<string, string> = {
"nav_vault_items": "Tresor", "nav_vault_items": "Tresor",
"nav_sends": "Sendungen", "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", "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_backup_strategy": "Cloud-Backup",
"nav_import_export": "Import und Export", "nav_import_export": "Import und Export",
"nav_group_data_backup": "Daten & Backup", "nav_group_data_backup": "Daten & Backup",
+34
View File
@@ -31,6 +31,40 @@ const en: Record<string, string> = {
"txt_generator_separator": "Word separator", "txt_generator_separator": "Word separator",
"txt_generator_capitalize": "Capitalize", "txt_generator_capitalize": "Capitalize",
"txt_generator_include_number": "Include a number", "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_backup_strategy": "Cloud Backup",
"nav_import_export": "Import & Export", "nav_import_export": "Import & Export",
"nav_group_data_backup": "Data & Backup", "nav_group_data_backup": "Data & Backup",
+4
View File
@@ -8,6 +8,10 @@ const es: Record<string, string> = {
"nav_vault_items": "Bóveda", "nav_vault_items": "Bóveda",
"nav_sends": "Envíos", "nav_sends": "Envíos",
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número", "nav_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_backup_strategy": "Copia de seguridad en la nube",
"nav_import_export": "Importar y exportar", "nav_import_export": "Importar y exportar",
"nav_group_data_backup": "Datos y copias", "nav_group_data_backup": "Datos y copias",
+4
View File
@@ -8,6 +8,10 @@ const fi: Record<string, string> = {
"nav_vault_items": "Holvi", "nav_vault_items": "Holvi",
"nav_sends": "Lähetykset", "nav_sends": "Lähetykset",
"nav_generator": "Luoja", "txt_password_generator": "Salasanageneraattori", "txt_password_generator_description": "Luo vahva ja yksilöllinen salasana paikallisesti tällä laitteella.", "txt_generator_type": "Generaattorin tyyppi", "txt_passphrase": "Salalause", "txt_generated_password": "Luotu salasana", "txt_password_strength": "Vahvuus", "txt_password_strength_weak": "Heikko", "txt_password_strength_fair": "Kohtalainen", "txt_password_strength_good": "Hyvä", "txt_password_strength_strong": "Vahva", "txt_generator_security_note": "Generointi tapahtuu paikallisesti. Salasanaa ei koskaan lähetetä palvelimelle.", "txt_generator_length": "Pituus", "txt_generator_character_types": "Merkkityypit", "txt_generator_uppercase": "Isot kirjaimet (A-Z)", "txt_generator_lowercase": "Pienet kirjaimet (a-z)", "txt_generator_numbers": "Numerot (0-9)", "txt_generator_special": "Erikoismerkit (!@#$%^&*)", "txt_generator_minimum": "Vähintään", "txt_generator_avoid_ambiguous": "Vältä epäselviä merkkejä", "txt_generator_words": "Sanojen määrä", "txt_generator_separator": "Sanaerotin", "txt_generator_capitalize": "Iso alkukirjain", "txt_generator_include_number": "Sisällytä numero", "nav_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_backup_strategy": "Pilvivarmuuskopiointi",
"nav_import_export": "Tuonti ja Vienti", "nav_import_export": "Tuonti ja Vienti",
"nav_group_data_backup": "Data & Varmuuskopiointi", "nav_group_data_backup": "Data & Varmuuskopiointi",
+4
View File
@@ -8,6 +8,10 @@ const fr: Record<string, string> = {
"nav_vault_items": "Coffre-fort", "nav_vault_items": "Coffre-fort",
"nav_sends": "Envois", "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", "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 dutilisateur", "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 dalias", "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 lalias.", "txt_generator_long_word_username_description": "Combinez un ou plusieurs mots longs en un identifiant mémorable.",
"nav_backup_strategy": "Sauvegarde Cloud", "nav_backup_strategy": "Sauvegarde Cloud",
"nav_import_export": "Importer & Exporter", "nav_import_export": "Importer & Exporter",
"nav_group_data_backup": "Données & Sauvegarde", "nav_group_data_backup": "Données & Sauvegarde",
+4
View File
@@ -8,6 +8,10 @@ const it: Record<string, string> = {
"nav_vault_items": "Cassaforte", "nav_vault_items": "Cassaforte",
"nav_sends": "Invii", "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", "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 lindirizzo email o il dominio per generare un alias.", "txt_generator_email_description": "I dati restano su questo dispositivo e servono solo a comporre lalias.", "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_backup_strategy": "Backup su Cloud",
"nav_import_export": "Importa ed Esporta", "nav_import_export": "Importa ed Esporta",
"nav_group_data_backup": "Dati e Backup", "nav_group_data_backup": "Dati e Backup",
+4
View File
@@ -9,6 +9,10 @@ const ru: Record<string, string> = {
"nav_vault_items": "Хранилище", "nav_vault_items": "Хранилище",
"nav_sends": "Отправляет", "nav_sends": "Отправляет",
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число", "nav_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_backup_strategy": "Облачное резервное копирование",
"nav_import_export": "Импорт и экспорт", "nav_import_export": "Импорт и экспорт",
"nav_group_data_backup": "Данные и резервные копии", "nav_group_data_backup": "Данные и резервные копии",
+4
View File
@@ -8,6 +8,10 @@ const sv: Record<string, string> = {
"nav_vault_items": "Valv", "nav_vault_items": "Valv",
"nav_sends": "Skickat", "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", "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_backup_strategy": "Molnsäkerhetskopiering",
"nav_import_export": "Importera och Exportera", "nav_import_export": "Importera och Exportera",
"nav_group_data_backup": "Data och Säkerhetskopiering", "nav_group_data_backup": "Data och Säkerhetskopiering",
+4
View File
@@ -11,6 +11,10 @@ const zhCN: Record<string, string> = {
"txt_password_generator": "密码生成器", "txt_password_generator": "密码生成器",
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。", "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_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_backup_strategy": "云端备份",
"nav_import_export": "导入导出", "nav_import_export": "导入导出",
"nav_group_data_backup": "数据与备份", "nav_group_data_backup": "数据与备份",
+4
View File
@@ -11,6 +11,10 @@ const zhTW: Record<string, string> = {
"txt_password_generator": "密碼產生器", "txt_password_generator": "密碼產生器",
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。", "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_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_backup_strategy": "雲端備份",
"nav_import_export": "導入導出", "nav_import_export": "導入導出",
"nav_group_data_backup": "資料與備份", "nav_group_data_backup": "資料與備份",
+271
View File
@@ -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)));
}
+141
View File
@@ -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);
}
+11 -4
View File
@@ -10,8 +10,10 @@
.generator-options-card { grid-area: options; padding: 19px; } .generator-options-card { grid-area: options; padding: 19px; }
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; } .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-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-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-strength-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; } .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 { display: flex; flex: 1; gap: 4px; }
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); } .generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); } .generator-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-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-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-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; } .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 (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; } } @media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; }.generator-spinner { animation: none; } }
+22 -61
View File
@@ -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 }) => { export default defineConfig(({ mode }) => {
const isDemo = mode === 'demo'; const isDemo = mode === 'demo';
return { return {
root: rootDir, root: rootDir,
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)], plugins: [preact(), searchIndexPolicyPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
define: { define: {
__NODEWARDEN_DEMO__: JSON.stringify(isDemo), __NODEWARDEN_DEMO__: JSON.stringify(isDemo),
}, },
@@ -311,41 +284,29 @@ export default defineConfig(({ mode }) => {
sourcemap: false, sourcemap: false,
target: 'esnext', target: 'esnext',
chunkSizeWarningLimit: 800, chunkSizeWarningLimit: 800,
rollupOptions: { rolldownOptions: {
treeshake: { checks: {
preset: 'smallest', pluginTimings: false,
}, },
output: { output: {
manualChunks(id) { codeSplitting: {
const normalized = id.replace(/\\/g, '/'); groups: [
{
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/); name: 'shared',
if (localeMatch) { minShareCount: 2,
if (localeMatch[1] === 'en') return undefined; minSize: 50 * 1024,
return `i18n-${localeMatch[1]}`; priority: 10,
} },
{
if ( name(id) {
!isDemo && const normalized = id.replace(/\\/g, '/');
( const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
normalized.includes('/src/components/VaultPage.tsx') || return localeMatch && localeMatch[1] !== 'en' ? `i18n-${localeMatch[1]}` : null;
normalized.includes('/src/components/ImportPage.tsx') || },
normalized.includes('/src/lib/import-') || test: /[\\/]src[\\/]lib[\\/]i18n[\\/]locales[\\/]/,
normalized.includes('/src/lib/export-formats.ts') || priority: 20,
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;
}, },
}, },
}, },