Add secure password generator
This commit is contained in:
@@ -534,7 +534,7 @@ export default function CredentialEnvelopeManager({
|
||||
label={secretFieldLabel(draft.credentialKind)}
|
||||
help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined}
|
||||
>
|
||||
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" />
|
||||
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" generator />
|
||||
</FormField>
|
||||
{editing !== "new" && (
|
||||
<ToggleSwitch
|
||||
|
||||
@@ -23,6 +23,7 @@ export type CredentialFieldsProps = {
|
||||
passwordPlaceholder?: string;
|
||||
usernameAutoComplete?: string;
|
||||
passwordAutoComplete?: string;
|
||||
passwordGenerator?: boolean;
|
||||
showUsername?: boolean;
|
||||
showPassword?: boolean;
|
||||
};
|
||||
@@ -49,6 +50,7 @@ export function CredentialFields({
|
||||
passwordPlaceholder,
|
||||
usernameAutoComplete = "username",
|
||||
passwordAutoComplete = "new-password",
|
||||
passwordGenerator = true,
|
||||
showUsername = true,
|
||||
showPassword = true
|
||||
}: CredentialFieldsProps) {
|
||||
@@ -74,6 +76,7 @@ export function CredentialFields({
|
||||
savedPlaceholder={savedPasswordPlaceholder}
|
||||
placeholder={passwordPlaceholder}
|
||||
autoComplete={passwordAutoComplete}
|
||||
generator={passwordGenerator}
|
||||
onValueChange={(password) => onChange({ password })} />
|
||||
</FormField>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useId, useState, type InputHTMLAttributes } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Dice5, Eye, EyeOff } from "lucide-react";
|
||||
import PasswordGeneratorDialog from "./PasswordGeneratorDialog";
|
||||
import type { PasswordGeneratorOptions } from "./passwordGenerator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
|
||||
value: string;
|
||||
@@ -8,6 +11,9 @@ export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "ty
|
||||
savedPlaceholder?: string;
|
||||
revealLabel?: string;
|
||||
hideLabel?: string;
|
||||
generator?: boolean;
|
||||
generatorLabel?: string;
|
||||
generatorOptions?: Partial<PasswordGeneratorOptions>;
|
||||
inputClassName?: string;
|
||||
};
|
||||
|
||||
@@ -18,6 +24,9 @@ export default function PasswordField({
|
||||
savedPlaceholder = "••••••••",
|
||||
revealLabel = "i18n:govoplan-core.show_password.044b852f",
|
||||
hideLabel = "i18n:govoplan-core.hide_password.e40123b4",
|
||||
generator = false,
|
||||
generatorLabel = "i18n:govoplan-core.generate_password.bd5bede8",
|
||||
generatorOptions,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className = "",
|
||||
@@ -26,39 +35,72 @@ export default function PasswordField({
|
||||
...inputProps
|
||||
}: PasswordFieldProps) {
|
||||
const generatedId = useId();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const inputId = id ?? generatedId;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [generatorOpen, setGeneratorOpen] = useState(false);
|
||||
const hasTypedPassword = value.length > 0;
|
||||
const showSavedPlaceholder = saved && !hasTypedPassword;
|
||||
const canReveal = hasTypedPassword && !disabled;
|
||||
const canGenerate = generator && !disabled && !inputProps.readOnly;
|
||||
const inputType = visible && canReveal ? "text" : "password";
|
||||
const translatedGeneratorLabel = translateText(generatorLabel);
|
||||
const translatedRevealLabel = translateText(revealLabel);
|
||||
const translatedHideLabel = translateText(hideLabel);
|
||||
|
||||
return (
|
||||
<div className={`password-field ${canReveal ? "has-toggle" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}>
|
||||
<input
|
||||
{...inputProps}
|
||||
id={inputId}
|
||||
className={inputClassName}
|
||||
type={inputType}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={showSavedPlaceholder ? savedPlaceholder : placeholder}
|
||||
onChange={(event) => {
|
||||
onValueChange(event.target.value);
|
||||
if (!event.target.value) setVisible(false);
|
||||
}} />
|
||||
|
||||
{canReveal &&
|
||||
<button
|
||||
type="button"
|
||||
className="password-field-toggle"
|
||||
aria-label={visible ? hideLabel : revealLabel}
|
||||
title={visible ? hideLabel : revealLabel}
|
||||
onClick={() => setVisible((current) => !current)}>
|
||||
|
||||
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||
</button>
|
||||
}
|
||||
</div>);
|
||||
<>
|
||||
<div className={`password-field ${canReveal || canGenerate ? "has-actions" : ""} ${canGenerate ? "has-generator" : ""} ${canReveal ? "has-reveal" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}>
|
||||
<input
|
||||
{...inputProps}
|
||||
id={inputId}
|
||||
className={inputClassName}
|
||||
type={inputType}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={showSavedPlaceholder ? savedPlaceholder : placeholder}
|
||||
onChange={(event) => {
|
||||
onValueChange(event.target.value);
|
||||
if (!event.target.value) setVisible(false);
|
||||
}} />
|
||||
|
||||
}
|
||||
{canReveal || canGenerate ? (
|
||||
<span className="password-field-actions">
|
||||
{canGenerate ? (
|
||||
<button
|
||||
type="button"
|
||||
className="password-field-action"
|
||||
aria-label={translatedGeneratorLabel}
|
||||
title={translatedGeneratorLabel}
|
||||
onClick={() => setGeneratorOpen(true)}
|
||||
>
|
||||
<Dice5 size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{canReveal ? (
|
||||
<button
|
||||
type="button"
|
||||
className="password-field-action"
|
||||
aria-label={visible ? translatedHideLabel : translatedRevealLabel}
|
||||
title={visible ? translatedHideLabel : translatedRevealLabel}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
>
|
||||
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PasswordGeneratorDialog
|
||||
open={generatorOpen}
|
||||
initialOptions={generatorOptions}
|
||||
onUse={(password) => {
|
||||
onValueChange(password);
|
||||
setVisible(false);
|
||||
}}
|
||||
onClose={() => setGeneratorOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Dice5, RefreshCw } from "lucide-react";
|
||||
import Button from "./Button";
|
||||
import Dialog from "./Dialog";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
import FormField from "./FormField";
|
||||
import IconButton from "./IconButton";
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import {
|
||||
DEFAULT_PASSWORD_GENERATOR_OPTIONS,
|
||||
generateSecurePassword,
|
||||
PasswordGeneratorError,
|
||||
type PasswordGeneratorErrorCode,
|
||||
type PasswordGeneratorOptions
|
||||
} from "./passwordGenerator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
const GENERATION_ERROR_LABELS: Record<PasswordGeneratorErrorCode, string> = {
|
||||
"invalid-length": "i18n:govoplan-core.password_length_must_be_between_12_and_128_character.4d147c07",
|
||||
"no-character-set": "i18n:govoplan-core.select_at_least_one_character_set.6150a9c2",
|
||||
"length-too-short": "i18n:govoplan-core.password_length_is_too_short_for_the_selected_charact.1d7845f1",
|
||||
"invalid-random-range": "i18n:govoplan-core.password_generation_could_not_use_the_selected_setti.c2f0da38",
|
||||
"secure-random-unavailable": "i18n:govoplan-core.secure_browser_password_generation_is_unavailable.55275f10"
|
||||
};
|
||||
|
||||
export type PasswordGeneratorDialogProps = {
|
||||
open: boolean;
|
||||
initialOptions?: Partial<PasswordGeneratorOptions>;
|
||||
onUse: (password: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function PasswordGeneratorDialog({
|
||||
open,
|
||||
initialOptions,
|
||||
onUse,
|
||||
onClose
|
||||
}: PasswordGeneratorDialogProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const [options, setOptions] = useState<PasswordGeneratorOptions>({
|
||||
...DEFAULT_PASSWORD_GENERATOR_OPTIONS,
|
||||
...initialOptions
|
||||
});
|
||||
const [candidate, setCandidate] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
try {
|
||||
setCandidate(generateSecurePassword(options));
|
||||
setError("");
|
||||
setCopied(false);
|
||||
} catch (generationError) {
|
||||
setCandidate("");
|
||||
setError(translateText(
|
||||
generationError instanceof PasswordGeneratorError
|
||||
? GENERATION_ERROR_LABELS[generationError.code]
|
||||
: "i18n:govoplan-core.password_generation_failed.5a40a004"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setOptions({ ...DEFAULT_PASSWORD_GENERATOR_OPTIONS, ...initialOptions });
|
||||
}, [initialOptions, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) generate();
|
||||
// Generation must react to the exact selected candidate policy.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, options]);
|
||||
|
||||
const setOption = <K extends keyof PasswordGeneratorOptions>(
|
||||
key: K,
|
||||
value: PasswordGeneratorOptions[K]
|
||||
) => setOptions((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const copy = async () => {
|
||||
if (!candidate || typeof navigator === "undefined" || !navigator.clipboard?.writeText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(candidate);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
setError(translateText("i18n:govoplan-core.the_generated_password_could_not_be_copied_select_it.569e025e"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="i18n:govoplan-core.generate_password.bd5bede8"
|
||||
className="password-generator-dialog"
|
||||
bodyClassName="password-generator-body"
|
||||
footerClassName="button-row compact-actions"
|
||||
portal
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" onClick={onClose}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!candidate}
|
||||
onClick={() => {
|
||||
if (!candidate) return;
|
||||
onUse(candidate);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Dice5 size={16} aria-hidden="true" /> i18n:govoplan-core.use_password.2e1913a6
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<div className="password-generator-options">
|
||||
<FormField label="i18n:govoplan-core.length.adc95605">
|
||||
<input
|
||||
type="number"
|
||||
min={12}
|
||||
max={128}
|
||||
step={1}
|
||||
value={options.length}
|
||||
onChange={(event) => setOption("length", Number(event.target.value))}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="password-generator-character-sets" aria-label={translateText("i18n:govoplan-core.character_sets.db6efda2")}>
|
||||
<ToggleSwitch label="i18n:govoplan-core.lowercase.3b677a18" checked={options.lowercase} onChange={(checked) => setOption("lowercase", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.uppercase.b463d690" checked={options.uppercase} onChange={(checked) => setOption("uppercase", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.digits.9cd500d3" checked={options.digits} onChange={(checked) => setOption("digits", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.symbols.9491fc41" checked={options.symbols} onChange={(checked) => setOption("symbols", checked)} />
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="i18n:govoplan-core.generated_password.78461854">
|
||||
<div className="password-generator-result">
|
||||
<input
|
||||
type="text"
|
||||
value={candidate}
|
||||
readOnly
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<IconButton
|
||||
label={copied ? "i18n:govoplan-core.copied.8d525e5f" : "i18n:govoplan-core.copy_generated_password.180ca876"}
|
||||
icon={<Copy size={16} />}
|
||||
onClick={() => void copy()}
|
||||
disabled={!candidate || typeof navigator === "undefined" || !navigator.clipboard?.writeText}
|
||||
/>
|
||||
<IconButton
|
||||
label="i18n:govoplan-core.generate_another_password.d99fc019"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={generate}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<p className="password-generator-note">
|
||||
i18n:govoplan-core.the_current_field_is_not_changed_until_you_choose_use_pass.447608e7
|
||||
</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export type PasswordGeneratorOptions = {
|
||||
length: number;
|
||||
lowercase: boolean;
|
||||
uppercase: boolean;
|
||||
digits: boolean;
|
||||
symbols: boolean;
|
||||
};
|
||||
|
||||
export type SecureRandomFill = (values: Uint32Array) => Uint32Array;
|
||||
|
||||
export type PasswordGeneratorErrorCode =
|
||||
| "invalid-length"
|
||||
| "no-character-set"
|
||||
| "length-too-short"
|
||||
| "invalid-random-range"
|
||||
| "secure-random-unavailable";
|
||||
|
||||
export class PasswordGeneratorError extends Error {
|
||||
readonly code: PasswordGeneratorErrorCode;
|
||||
|
||||
constructor(code: PasswordGeneratorErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "PasswordGeneratorError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_PASSWORD_GENERATOR_OPTIONS: PasswordGeneratorOptions = {
|
||||
length: 20,
|
||||
lowercase: true,
|
||||
uppercase: true,
|
||||
digits: true,
|
||||
symbols: true
|
||||
};
|
||||
|
||||
const CHARACTER_SETS = {
|
||||
lowercase: "abcdefghijkmnopqrstuvwxyz",
|
||||
uppercase: "ABCDEFGHJKLMNPQRSTUVWXYZ",
|
||||
digits: "23456789",
|
||||
symbols: "!#$%&*+-=?@^_"
|
||||
} as const;
|
||||
|
||||
export function generateSecurePassword(
|
||||
options: PasswordGeneratorOptions,
|
||||
randomFill: SecureRandomFill = browserSecureRandomFill
|
||||
): string {
|
||||
const length = Math.trunc(options.length);
|
||||
if (length < 12 || length > 128) {
|
||||
throw new PasswordGeneratorError("invalid-length", "Password length must be between 12 and 128 characters.");
|
||||
}
|
||||
|
||||
const selectedSets = (Object.keys(CHARACTER_SETS) as Array<keyof typeof CHARACTER_SETS>)
|
||||
.filter((key) => options[key])
|
||||
.map((key) => CHARACTER_SETS[key]);
|
||||
if (selectedSets.length === 0) {
|
||||
throw new PasswordGeneratorError("no-character-set", "Select at least one character set.");
|
||||
}
|
||||
if (length < selectedSets.length) {
|
||||
throw new PasswordGeneratorError("length-too-short", "Password length is too short for the selected character sets.");
|
||||
}
|
||||
|
||||
const pool = selectedSets.join("");
|
||||
const characters = selectedSets.map((set) => set[randomIndex(set.length, randomFill)]);
|
||||
while (characters.length < length) {
|
||||
characters.push(pool[randomIndex(pool.length, randomFill)]);
|
||||
}
|
||||
for (let index = characters.length - 1; index > 0; index -= 1) {
|
||||
const target = randomIndex(index + 1, randomFill);
|
||||
[characters[index], characters[target]] = [characters[target], characters[index]];
|
||||
}
|
||||
return characters.join("");
|
||||
}
|
||||
|
||||
function randomIndex(maxExclusive: number, randomFill: SecureRandomFill): number {
|
||||
if (!Number.isSafeInteger(maxExclusive) || maxExclusive < 1 || maxExclusive > 0x1_0000_0000) {
|
||||
throw new PasswordGeneratorError("invalid-random-range", "Invalid secure-random range.");
|
||||
}
|
||||
const range = 0x1_0000_0000;
|
||||
const ceiling = range - (range % maxExclusive);
|
||||
const values = new Uint32Array(1);
|
||||
do {
|
||||
randomFill(values);
|
||||
} while (values[0] >= ceiling);
|
||||
return values[0] % maxExclusive;
|
||||
}
|
||||
|
||||
function browserSecureRandomFill(values: Uint32Array): Uint32Array {
|
||||
const cryptoProvider = globalThis.crypto;
|
||||
if (!cryptoProvider?.getRandomValues) {
|
||||
throw new PasswordGeneratorError("secure-random-unavailable", "Secure browser password generation is unavailable.");
|
||||
}
|
||||
return cryptoProvider.getRandomValues(values);
|
||||
}
|
||||
Reference in New Issue
Block a user