* Copyright (C) 2023 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import { debounce } from "throttle-debounce";
import React, { useEffect, useMemo, useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { FormGroup, FormHelperText, FormSection } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
import { InputGroup, InputGroupItem } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
import { CheckCircleIcon } from "@patternfly/react-icons/dist/esm/icons/check-circle-icon";
import { ExclamationCircleIcon } from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon";
import { ExclamationTriangleIcon } from "@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon";
import { EyeIcon } from "@patternfly/react-icons/dist/esm/icons/eye-icon";
import { EyeSlashIcon } from "@patternfly/react-icons/dist/esm/icons/eye-slash-icon";
import { password_quality } from "cockpit-components-password.jsx";
const _ = cockpit.gettext;
export const ruleLength = {
check: (policy, password) => password.length >= policy["min-length"].v,
id: "length",
isError: true,
text: (policy) => cockpit.format(_("Must be at least $0 characters"), policy["min-length"].v),
};
* If the policy specifies a 'is-strict' rule anything bellow the minimum specified by the policy
* is considered invalid
* @param {int} minQualility - the minimum quality level
* @return {array} - the password strengh levels
*/
const getStrengthLevels = (minQualility, isStrict) => {
const levels = [{
higher_bound: minQualility - 1,
icon: <ExclamationCircleIcon />,
id: "weak",
label: _("Weak"),
lower_bound: 0,
valid: !isStrict,
variant: "error",
}];
if (minQualility <= 69) {
levels.push({
higher_bound: 69,
icon: <ExclamationTriangleIcon />,
id: "medium",
label: _("Medium"),
lower_bound: minQualility,
valid: true,
variant: "warning",
});
}
levels.push({
higher_bound: 100,
icon: <CheckCircleIcon />,
id: "strong",
label: _("Strong"),
lower_bound: Math.max(70, minQualility),
valid: true,
variant: "success",
});
return levels;
};
const getRuleResults = (rules, policy, password) => {
return rules.map(rule => {
return {
id: rule.id,
isError: rule.isError,
isSatisfied: password.length > 0 ? rule.check(policy, password) : null,
text: rule.text(policy, password)
};
});
};
const rulesSatisfied = ruleResults => ruleResults.every(r => r.isSatisfied || !r.isError);
const passwordStrengthLabel = (idPrefix, strength, strengthLevels) => {
const level = strengthLevels.filter(l => l.id === strength)[0];
if (level) {
return (
<HelperText>
<HelperTextItem id={idPrefix + "-password-strength-label"} variant={level.variant} icon={level.icon}>
{level.label}
</HelperTextItem>
</HelperText>
);
}
};
export const PasswordFormFields = ({
confirmPassword,
confirmPasswordLabel,
emptyPasswordHelperText,
idPrefix,
password,
passwordLabel,
passwordRef,
policy,
rules,
sectionDescription,
setConfirmPassword,
setIsValid,
setPassword,
}) => {
const [passwordHidden, setPasswordHidden] = useState(true);
const [confirmHidden, setConfirmHidden] = useState(true);
const [checkPassword, setCheckPassword] = useState(password);
const [checkConfirmPassword, setCheckConfirmPassword] = useState(confirmPassword);
const [passwordStrength, setPasswordStrength] = useState("");
useEffect(() => {
debounce(300, () => setCheckPassword(password))();
}, [password, setCheckPassword]);
useEffect(() => {
debounce(300, () => setCheckConfirmPassword(confirmPassword))();
}, [confirmPassword, setCheckConfirmPassword]);
const ruleResults = useMemo(() => {
return getRuleResults(rules, policy, checkPassword);
}, [policy, checkPassword, rules]);
const allowEmptyPassword = policy?.["allow-empty"]?.v ?? false;
const bothFieldsEmpty = (checkPassword?.length ?? 0) === 0 && (checkConfirmPassword?.length ?? 0) === 0;
const isEmptyPasswordAccepted = allowEmptyPassword && bothFieldsEmpty;
const ruleConfirmMatches = bothFieldsEmpty
? (allowEmptyPassword ? true : null)
: checkPassword === checkConfirmPassword;
const ruleHelperItems = ruleResults.map(rule => {
let variant = rule.isSatisfied === null ? "indeterminate" : rule.isSatisfied ? "success" : "error";
if (!rule.isError) {
if (rule.isSatisfied || rule.isSatisfied === null) {
return null;
}
variant = "warning";
}
return (
<HelperTextItem
key={rule.id}
id={idPrefix + "-password-rule-" + rule.id}
variant={variant}
component="li"
>
{rule.text}
</HelperTextItem>
);
});
const ruleConfirmVariant = ruleConfirmMatches === null ? "indeterminate" : ruleConfirmMatches ? "success" : "error";
const strengthLevels = useMemo(() => {
return policy && getStrengthLevels(policy["min-quality"].v, policy["is-strict"].v);
}, [policy]);
useEffect(() => {
const updatePasswordStrength = async () => {
const _passwordStrength = await getPasswordStrength(checkPassword, strengthLevels);
setPasswordStrength(_passwordStrength);
};
updatePasswordStrength();
}, [checkPassword, strengthLevels]);
useEffect(() => {
const isStrict = policy?.["is-strict"]?.v ?? false;
if (isEmptyPasswordAccepted) {
setIsValid(true);
return;
}
if (!ruleConfirmMatches) {
setIsValid(false);
return;
}
if (isStrict) {
setIsValid(!!(rulesSatisfied(ruleResults) && isValidStrength(passwordStrength, strengthLevels)));
return;
}
setIsValid((checkPassword?.length ?? 0) > 0);
}, [
checkPassword,
isEmptyPasswordAccepted,
policy,
ruleConfirmMatches,
ruleResults,
passwordStrength,
setIsValid,
strengthLevels,
]);
return (
<FormSection>
{sectionDescription}
<FormGroup
isRequired
label={passwordLabel}
labelInfo={rulesSatisfied(ruleResults) && passwordStrengthLabel(idPrefix, passwordStrength, strengthLevels)}
>
<InputGroup>
<InputGroupItem isFill>
<TextInput
type={passwordHidden ? "password" : "text"}
value={password}
onChange={(_event, val) => setPassword(val)}
id={idPrefix + "-password-field"}
ref={passwordRef}
/>
</InputGroupItem>
<InputGroupItem>
<Button
variant="control"
onClick={() => setPasswordHidden(!passwordHidden)}
aria-label={passwordHidden ? _("Show password") : _("Hide password")}
>
{passwordHidden ? <EyeIcon /> : <EyeSlashIcon />}
</Button>
</InputGroupItem>
</InputGroup>
<FormHelperText>
<HelperText component="ul" aria-live="polite" id={idPrefix + "-password-field-helper"}>
{ruleHelperItems}
{allowEmptyPassword && emptyPasswordHelperText && (
<HelperTextItem
component="li"
variant="default"
>
{emptyPasswordHelperText}
</HelperTextItem>
)}
</HelperText>
</FormHelperText>
</FormGroup>
<FormGroup
isRequired
label={confirmPasswordLabel}
>
<InputGroup>
<InputGroupItem isFill><TextInput
type={confirmHidden ? "password" : "text"}
value={confirmPassword}
onChange={(_event, val) => setConfirmPassword(val)}
id={idPrefix + "-password-confirm-field"}
/>
</InputGroupItem>
<InputGroupItem>
<Button
variant="control"
onClick={() => setConfirmHidden(!confirmHidden)}
aria-label={confirmHidden ? _("Show confirmed password") : _("Hide confirmed password")}
>
{confirmHidden ? <EyeIcon /> : <EyeSlashIcon />}
</Button>
</InputGroupItem>
</InputGroup>
<FormHelperText>
<HelperText component="ul" aria-live="polite" id="password-confirm-field-helper">
<HelperTextItem
id={idPrefix + "-password-rule-match"}
variant={ruleConfirmVariant}
component="li"
>
{_("Passphrases must match")}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
</FormSection>
);
};
const getPasswordStrength = async (password, strengthLevels) => {
const force = true;
const quality = await password_quality(password, force);
const level = strengthLevels.filter(l => l.lower_bound <= quality.value && l.higher_bound >= quality.value)[0];
return level.id;
};
const isValidStrength = (strength, strengthLevels) => {
const level = strengthLevels.filter(l => l.id === strength)[0];
return level ? level.valid : false;
};