From 0c9bf6758c6e6617db055719878a8e49e1ae02b4 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 11:07:55 +0200 Subject: [PATCH] Add secure password generator --- docs/INTERFACE_PATTERN_MIGRATION.md | 2 +- docs/UI_UX_DECISION_LEDGER.md | 2 +- webui/package.json | 1 + .../components/CredentialEnvelopeManager.tsx | 2 +- webui/src/components/CredentialPanel.tsx | 3 + webui/src/components/PasswordField.tsx | 98 ++++++++--- .../components/PasswordGeneratorDialog.tsx | 163 ++++++++++++++++++ webui/src/components/passwordGenerator.ts | 93 ++++++++++ webui/src/i18n/generatedTranslations.ts | 40 +++++ webui/src/index.ts | 4 + webui/src/styles/components.css | 52 +++++- webui/tests/mail-components.test.tsx | 7 +- webui/tests/password-generator.test.tsx | 66 +++++++ webui/tsconfig.component-tests.json | 3 + 14 files changed, 497 insertions(+), 39 deletions(-) create mode 100644 webui/src/components/PasswordGeneratorDialog.tsx create mode 100644 webui/src/components/passwordGenerator.ts create mode 100644 webui/tests/password-generator.test.tsx diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index b803a21..2f89818 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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 | diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index 0b9ea47..ed1b1ea 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -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. | diff --git a/webui/package.json b/webui/package.json index aade306..4ef6bb2 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/src/components/CredentialEnvelopeManager.tsx b/webui/src/components/CredentialEnvelopeManager.tsx index 2a325fb..085f190 100644 --- a/webui/src/components/CredentialEnvelopeManager.tsx +++ b/webui/src/components/CredentialEnvelopeManager.tsx @@ -534,7 +534,7 @@ export default function CredentialEnvelopeManager({ label={secretFieldLabel(draft.credentialKind)} help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined} > - setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" /> + setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" generator /> {editing !== "new" && ( onChange({ password })} /> } diff --git a/webui/src/components/PasswordField.tsx b/webui/src/components/PasswordField.tsx index 13bf88d..047e1b4 100644 --- a/webui/src/components/PasswordField.tsx +++ b/webui/src/components/PasswordField.tsx @@ -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, "type" | "value" | "onChange"> & { value: string; @@ -8,6 +11,9 @@ export type PasswordFieldProps = Omit, "ty savedPlaceholder?: string; revealLabel?: string; hideLabel?: string; + generator?: boolean; + generatorLabel?: string; + generatorOptions?: Partial; 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 ( -
- { - onValueChange(event.target.value); - if (!event.target.value) setVisible(false); - }} /> - - {canReveal && - - } -
); + <> +
+ { + onValueChange(event.target.value); + if (!event.target.value) setVisible(false); + }} /> -} \ No newline at end of file + {canReveal || canGenerate ? ( + + {canGenerate ? ( + + ) : null} + {canReveal ? ( + + ) : null} + + ) : null} +
+ + { + onValueChange(password); + setVisible(false); + }} + onClose={() => setGeneratorOpen(false)} + /> + + ); +} diff --git a/webui/src/components/PasswordGeneratorDialog.tsx b/webui/src/components/PasswordGeneratorDialog.tsx new file mode 100644 index 0000000..f3b1b26 --- /dev/null +++ b/webui/src/components/PasswordGeneratorDialog.tsx @@ -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 = { + "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; + onUse: (password: string) => void; + onClose: () => void; +}; + +export default function PasswordGeneratorDialog({ + open, + initialOptions, + onUse, + onClose +}: PasswordGeneratorDialogProps) { + const { translateText } = usePlatformLanguage(); + const [options, setOptions] = useState({ + ...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 = ( + 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 ( + + + + + )} + > + {error ? {error} : null} +
+ + setOption("length", Number(event.target.value))} + /> + +
+ setOption("lowercase", checked)} /> + setOption("uppercase", checked)} /> + setOption("digits", checked)} /> + setOption("symbols", checked)} /> +
+
+ +
+ + } + onClick={() => void copy()} + disabled={!candidate || typeof navigator === "undefined" || !navigator.clipboard?.writeText} + /> + } + onClick={generate} + /> +
+
+

+ i18n:govoplan-core.the_current_field_is_not_changed_until_you_choose_use_pass.447608e7 +

+
+ ); +} diff --git a/webui/src/components/passwordGenerator.ts b/webui/src/components/passwordGenerator.ts new file mode 100644 index 0000000..5199f4f --- /dev/null +++ b/webui/src/components/passwordGenerator.ts @@ -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) + .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); +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 6b8aaaa..4beaf40 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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", diff --git a/webui/src/index.ts b/webui/src/index.ts index 59b7f6f..e998d7b 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 9129f0a..37254f8 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -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; diff --git a/webui/tests/mail-components.test.tsx b/webui/tests/mail-components.test.tsx index 59181da..d76a24c 100644 --- a/webui/tests/mail-components.test.tsx +++ b/webui/tests/mail-components.test.tsx @@ -48,13 +48,13 @@ const savedPassword = renderToStaticMarkup( ); 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( ); 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( @@ -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( { + 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( + + undefined} generator /> + +); +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."); diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json index 16db9ec..b5bf372 100644 --- a/webui/tsconfig.component-tests.json +++ b/webui/tsconfig.component-tests.json @@ -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",