Add secure password generator

This commit is contained in:
2026-08-04 11:07:55 +02:00
parent 25da7d49a9
commit 0c9bf6758c
14 changed files with 497 additions and 39 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ domain modules own their compositions.
| Surface | Pattern | Consequence and provenance contract | Evidence |
| --- | --- | --- | --- |
| User settings | Two-zone settings workspace with typed controls and unsaved-change protection | Save actions distinguish busy, unchanged, and test-in-progress states; contextual help resolves through Docs or the hosted fallback | `SettingsPage.tsx`, `test-core-interface-patterns.mjs` |
| Reusable credentials | Repeated administration with an adaptive create/edit dialog and destructive confirmation | Secret values are write-only; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build |
+1 -1
View File
@@ -281,7 +281,7 @@ UI documentation until a central cross-repository audit is available.
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
| --- | --- | --- |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. `PasswordField` may opt into the shared cryptographic generator; the candidate dialog is subordinate to the enclosing field and commits only through its explicit Use action. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
+1
View File
@@ -44,6 +44,7 @@
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
"test:password-field": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/password-generator.test.js",
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
"test:action-blocker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/action-blocker-hint.test.js",
"test:documentation-help": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/documentation-help-link.test.js",
@@ -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
+3
View File
@@ -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>
}
+70 -28
View File
@@ -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>
);
}
+93
View File
@@ -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);
}
+40
View File
@@ -2,6 +2,26 @@ import type { PlatformTranslations } from "../types";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-core.generate_password.bd5bede8": "Generate password",
"i18n:govoplan-core.use_password.2e1913a6": "Use password",
"i18n:govoplan-core.length.adc95605": "Length",
"i18n:govoplan-core.character_sets.db6efda2": "Character sets",
"i18n:govoplan-core.lowercase.3b677a18": "Lowercase",
"i18n:govoplan-core.uppercase.b463d690": "Uppercase",
"i18n:govoplan-core.digits.9cd500d3": "Digits",
"i18n:govoplan-core.symbols.9491fc41": "Symbols",
"i18n:govoplan-core.generated_password.78461854": "Generated password",
"i18n:govoplan-core.copied.8d525e5f": "Copied",
"i18n:govoplan-core.copy_generated_password.180ca876": "Copy generated password",
"i18n:govoplan-core.generate_another_password.d99fc019": "Generate another password",
"i18n:govoplan-core.the_current_field_is_not_changed_until_you_choose_use_pass.447608e7": "The current field is not changed until you choose Use password. Store or deliver credentials through an approved separate channel.",
"i18n:govoplan-core.password_length_must_be_between_12_and_128_character.4d147c07": "Password length must be between 12 and 128 characters.",
"i18n:govoplan-core.select_at_least_one_character_set.6150a9c2": "Select at least one character set.",
"i18n:govoplan-core.password_length_is_too_short_for_the_selected_charact.1d7845f1": "Password length is too short for the selected character sets.",
"i18n:govoplan-core.password_generation_could_not_use_the_selected_setti.c2f0da38": "Password generation could not use the selected settings.",
"i18n:govoplan-core.secure_browser_password_generation_is_unavailable.55275f10": "Secure browser password generation is unavailable.",
"i18n:govoplan-core.password_generation_failed.5a40a004": "Password generation failed.",
"i18n:govoplan-core.the_generated_password_could_not_be_copied_select_it.569e025e": "The generated password could not be copied. Select it manually instead.",
"i18n:govoplan-core.about_govoplan.1c6884f8": "About GovOPlaN",
"i18n:govoplan-core.about.6b21fb79": "About",
"i18n:govoplan-core.accent_color.e49578ed": "Accent color",
@@ -620,6 +640,26 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive"
},
"de": {
"i18n:govoplan-core.generate_password.bd5bede8": "Passwort generieren",
"i18n:govoplan-core.use_password.2e1913a6": "Passwort verwenden",
"i18n:govoplan-core.length.adc95605": "Länge",
"i18n:govoplan-core.character_sets.db6efda2": "Zeichensätze",
"i18n:govoplan-core.lowercase.3b677a18": "Kleinbuchstaben",
"i18n:govoplan-core.uppercase.b463d690": "Großbuchstaben",
"i18n:govoplan-core.digits.9cd500d3": "Ziffern",
"i18n:govoplan-core.symbols.9491fc41": "Sonderzeichen",
"i18n:govoplan-core.generated_password.78461854": "Generiertes Passwort",
"i18n:govoplan-core.copied.8d525e5f": "Kopiert",
"i18n:govoplan-core.copy_generated_password.180ca876": "Generiertes Passwort kopieren",
"i18n:govoplan-core.generate_another_password.d99fc019": "Anderes Passwort generieren",
"i18n:govoplan-core.the_current_field_is_not_changed_until_you_choose_use_pass.447608e7": "Das aktuelle Feld wird erst geändert, wenn Sie Passwort verwenden wählen. Speichern oder übermitteln Sie Zugangsdaten über einen freigegebenen separaten Kanal.",
"i18n:govoplan-core.password_length_must_be_between_12_and_128_character.4d147c07": "Die Passwortlänge muss zwischen 12 und 128 Zeichen liegen.",
"i18n:govoplan-core.select_at_least_one_character_set.6150a9c2": "Wählen Sie mindestens einen Zeichensatz aus.",
"i18n:govoplan-core.password_length_is_too_short_for_the_selected_charact.1d7845f1": "Die Passwortlänge ist für die ausgewählten Zeichensätze zu kurz.",
"i18n:govoplan-core.password_generation_could_not_use_the_selected_setti.c2f0da38": "Die Passwortgenerierung konnte die ausgewählten Einstellungen nicht verwenden.",
"i18n:govoplan-core.secure_browser_password_generation_is_unavailable.55275f10": "Die sichere Passwortgenerierung ist in diesem Browser nicht verfügbar.",
"i18n:govoplan-core.password_generation_failed.5a40a004": "Die Passwortgenerierung ist fehlgeschlagen.",
"i18n:govoplan-core.the_generated_password_could_not_be_copied_select_it.569e025e": "Das generierte Passwort konnte nicht kopiert werden. Wählen Sie es stattdessen manuell aus.",
"i18n:govoplan-core.about_govoplan.1c6884f8": "About GovOPlaN",
"i18n:govoplan-core.about.6b21fb79": "About",
"i18n:govoplan-core.accent_color.e49578ed": "Accent color",
+4
View File
@@ -112,6 +112,7 @@ export { default as PageTitle } from "./components/PageTitle";
export { default as PageScrollViewport } from "./components/PageScrollViewport";
export type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as PasswordField } from "./components/PasswordField";
export { default as PasswordGeneratorDialog } from "./components/PasswordGeneratorDialog";
export { default as PeoplePicker } from "./components/people/PeoplePicker";
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
@@ -119,6 +120,9 @@ export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, People
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
export type { PasswordFieldProps } from "./components/PasswordField";
export type { PasswordGeneratorDialogProps } from "./components/PasswordGeneratorDialog";
export type { PasswordGeneratorErrorCode, PasswordGeneratorOptions } from "./components/passwordGenerator";
export { DEFAULT_PASSWORD_GENERATOR_OPTIONS, generateSecurePassword, PasswordGeneratorError } from "./components/passwordGenerator";
export { PolicyRow, PolicySection, PolicyTable } from "./components/PolicyTable";
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "./components/PolicyPathHelp";
export type { NormalizedPolicySourcePathItem, PolicyPathHelpProps } from "./components/PolicyPathHelp";
+47 -5
View File
@@ -195,17 +195,26 @@
position: relative;
width: 100%;
}
.password-field.has-toggle input {
.password-field.has-actions input {
padding-right: 42px;
}
.password-field.has-generator.has-reveal input {
padding-right: 74px;
}
.password-field.is-saved-empty input::placeholder {
color: var(--muted);
opacity: 1;
}
.password-field-toggle {
.password-field-actions {
position: absolute;
top: 50%;
right: 7px;
display: inline-flex;
align-items: center;
gap: 4px;
transform: translateY(-50%);
}
.password-field-action {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -217,17 +226,50 @@
color: var(--muted);
cursor: pointer;
padding: 0;
transform: translateY(-50%);
}
.password-field-toggle:hover {
.password-field-action:hover {
background: var(--hover-tint-soft);
color: var(--text-strong);
}
.password-field-toggle:focus-visible {
.password-field-action:focus-visible {
outline: var(--focus-outline);
outline-offset: 1px;
}
.password-generator-dialog {
width: min(520px, calc(100vw - 32px));
}
.password-generator-body,
.password-generator-options {
display: grid;
gap: 14px;
}
.password-generator-character-sets {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
}
.password-generator-result {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 6px;
align-items: center;
}
.password-generator-result input {
min-width: 0;
font-family: var(--font-mono, monospace);
}
.password-generator-note {
margin: 0;
color: var(--muted);
font-size: 0.875rem;
}
@media (max-width: 520px) {
.password-generator-character-sets {
grid-template-columns: 1fr;
}
}
.credential-panel {
display: grid;
gap: 12px;
+4 -3
View File
@@ -48,13 +48,13 @@ const savedPassword = renderToStaticMarkup(
<PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
);
assert(savedPassword.includes('placeholder="Saved password configured"'), "saved password placeholder is rendered");
assert(!savedPassword.includes("password-field-toggle"), "saved empty password does not show reveal button");
assert(!savedPassword.includes("password-field-action"), "saved empty password without generator does not show an action button");
const typedPassword = renderToStaticMarkup(
<PasswordField value="secret" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
);
assert(typedPassword.includes('aria-label="i18n:govoplan-core.show_password.044b852f"'), "typed password shows reveal button");
assert(typedPassword.includes("password-field-toggle"), "typed password gets toggle class");
assert(typedPassword.includes("password-field-action"), "typed password gets a reveal action");
const translatedAddressInput = renderToStaticMarkup(
<PlatformLanguageProvider>
@@ -83,7 +83,8 @@ const savedCredentialPanel = renderToStaticMarkup(
assert(savedCredentialPanel.includes("Credentials"), "credential panel renders heading");
assert(savedCredentialPanel.includes('value="sender"'), "credential panel renders username");
assert(savedCredentialPanel.includes('placeholder="Saved credential"'), "credential panel renders saved password placeholder");
assert(!savedCredentialPanel.includes("password-field-toggle"), "saved empty credential does not show reveal button");
assert(savedCredentialPanel.includes('aria-label="i18n:govoplan-core.generate_password.bd5bede8"'), "credential fields opt into the password generator");
assert(!savedCredentialPanel.includes('aria-label="i18n:govoplan-core.show_password.044b852f"'), "saved empty credential does not show a reveal action");
const settingsPanel = renderToStaticMarkup(
<MailServerSettingsPanel
+66
View File
@@ -0,0 +1,66 @@
function assert(condition: unknown, message = "assertion failed"): asserts condition {
if (!condition) throw new Error(message);
}
import { renderToStaticMarkup } from "react-dom/server";
import PasswordField from "../src/components/PasswordField";
import {
DEFAULT_PASSWORD_GENERATOR_OPTIONS,
generateSecurePassword,
type SecureRandomFill
} from "../src/components/passwordGenerator";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
function sequenceFill(sequence: number[]): SecureRandomFill {
let index = 0;
return (values) => {
values[0] = sequence[index % sequence.length] >>> 0;
index += 1;
return values;
};
}
const password = generateSecurePassword(
DEFAULT_PASSWORD_GENERATOR_OPTIONS,
sequenceFill(Array.from({ length: 64 }, (_, index) => index * 7919))
);
assert(password.length === 20, "the requested password length is preserved");
assert(/[a-z]/.test(password), "selected lowercase characters are represented");
assert(/[A-Z]/.test(password), "selected uppercase characters are represented");
assert(/[0-9]/.test(password), "selected digits are represented");
assert(/[!#$%&*+\-=?@^_]/.test(password), "selected symbols are represented");
let rejectedDraws = 0;
const rejectionAware = generateSecurePassword(
{ length: 12, lowercase: true, uppercase: false, digits: false, symbols: false },
(values) => {
values[0] = rejectedDraws++ % 2 === 0 ? 0xffff_ffff : 7;
return values;
}
);
assert(rejectionAware.length === 12, "out-of-range random draws are rejected without shortening output");
assert(rejectedDraws > 12, "modulo-bias rejection consumes a replacement secure draw");
for (const [options, expected] of [
[{ ...DEFAULT_PASSWORD_GENERATOR_OPTIONS, length: 11 }, "between 12 and 128"],
[{ ...DEFAULT_PASSWORD_GENERATOR_OPTIONS, lowercase: false, uppercase: false, digits: false, symbols: false }, "at least one character set"]
] as const) {
let message = "";
try {
generateSecurePassword(options, sequenceFill([1]));
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
assert(message.includes(expected), `invalid generator options should explain ${expected}`);
}
const markup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PasswordField value="" onValueChange={() => undefined} generator />
</PlatformLanguageProvider>
);
assert(markup.includes('aria-label="Generate password"'), "the opt-in generator action is accessible");
assert(markup.includes("lucide-dice-5"), "the familiar generator icon is used");
assert(!markup.includes("password-generator-dialog"), "the generator dialog stays closed until explicitly requested");
console.log("Password generator contract passed.");
+3
View File
@@ -28,6 +28,7 @@
"tests/mail-components.test.tsx",
"tests/metric-card.test.tsx",
"tests/people-picker.test.tsx",
"tests/password-generator.test.tsx",
"tests/resource-access-explanation.test.tsx",
"tests/selection-list.test.tsx",
"tests/wysiwyg-editor-utils.test.ts",
@@ -39,6 +40,8 @@
"src/components/help/FieldLabel.tsx",
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx",
"src/components/PasswordGeneratorDialog.tsx",
"src/components/passwordGenerator.ts",
"src/components/MessageDisplayPanel.tsx",
"src/components/MetricCard.tsx",
"src/components/people/PeoplePicker.tsx",