diff --git a/ui/webui/src/apis/users.js b/ui/webui/src/apis/users.js new file mode 100644 index 00000000000..a4d2bc6cc5a --- /dev/null +++ b/ui/webui/src/apis/users.js @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2023 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with This program; If not, see . + */ +import cockpit from "cockpit"; +import { _setProperty } from "./helpers.js"; + +const OBJECT_PATH = "/org/fedoraproject/Anaconda/Modules/Users"; +const INTERFACE_NAME = "org.fedoraproject.Anaconda.Modules.Users"; + +const setProperty = (...args) => { + return _setProperty(UsersClient, OBJECT_PATH, INTERFACE_NAME, ...args); +}; + +export class UsersClient { + constructor (address) { + if (UsersClient.instance && (!address || UsersClient.instance.address === address)) { + return UsersClient.instance; + } + + UsersClient.instance?.client.close(); + + UsersClient.instance = this; + + this.client = cockpit.dbus( + INTERFACE_NAME, + { superuser: "try", bus: "none", address } + ); + this.address = address; + } + + init () { + this.client.addEventListener( + "close", () => console.error("Users client closed") + ); + } +} + +/** + * @param {Array.} users An array of user objects + */ +export const setUsers = (users) => { + return setProperty("Users", cockpit.variant("aa{sv}", users)); +}; diff --git a/ui/webui/src/components/AnacondaWizard.jsx b/ui/webui/src/components/AnacondaWizard.jsx index eb6beff0d8a..9ae1ae3d446 100644 --- a/ui/webui/src/components/AnacondaWizard.jsx +++ b/ui/webui/src/components/AnacondaWizard.jsx @@ -39,6 +39,7 @@ import { getDefaultScenario } from "./storage/InstallationScenario.jsx"; import { MountPointMapping, getPageProps as getMountPointMappingProps } from "./storage/MountPointMapping.jsx"; import { DiskEncryption, getStorageEncryptionState, getPageProps as getDiskEncryptionProps } from "./storage/DiskEncryption.jsx"; import { InstallationLanguage, getPageProps as getInstallationLanguageProps } from "./localization/InstallationLanguage.jsx"; +import { Accounts, getPageProps as getAccountsProps, getAccountsState, accountsToDbusUsers } from "./users/Accounts.jsx"; import { InstallationProgress } from "./installation/InstallationProgress.jsx"; import { ReviewConfiguration, ReviewConfigurationConfirmModal, getPageProps as getReviewConfigurationProps } from "./review/ReviewConfiguration.jsx"; import { exitGui } from "../helpers/exit.js"; @@ -50,6 +51,9 @@ import { applyStorage, resetPartitioning, } from "../apis/storage_partitioning.js"; +import { + setUsers, +} from "../apis/users.js"; import { SystemTypeContext, OsReleaseContext } from "./Common.jsx"; const _ = cockpit.gettext; @@ -63,6 +67,7 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim const [stepNotification, setStepNotification] = useState(); const [storageEncryption, setStorageEncryption] = useState(getStorageEncryptionState()); const [storageScenarioId, setStorageScenarioId] = useState(window.sessionStorage.getItem("storage-scenario-id") || getDefaultScenario().id); + const [accounts, setAccounts] = useState(getAccountsState()); const [showWizard, setShowWizard] = useState(true); const osRelease = useContext(OsReleaseContext); const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; @@ -143,6 +148,15 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim ...getDiskEncryptionProps({ storageScenarioId }) }] }, + { + component: Accounts, + data: { + accounts, + setAccounts, + passwordPolicies: runtimeData.passwordPolicies, + }, + ...getAccountsProps({ isBootIso }) + }, { component: ReviewConfiguration, data: { @@ -178,10 +192,14 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim const firstStepId = stepsOrder.filter(step => !step.isHidden)[0].id; const currentStepId = path[0] || firstStepId; + const isStepFollowedBy = (earlierStepId, laterStepId) => { + const earlierStepIdx = flattenedStepsIds.findIndex(s => s === earlierStepId); + const laterStepIdx = flattenedStepsIds.findIndex(s => s === laterStepId); + return earlierStepIdx < laterStepIdx; + }; + const canJumpToStep = (stepId, currentStepId) => { - const stepIdx = flattenedStepsIds.findIndex(s => s === stepId); - const currentStepIdx = flattenedStepsIds.findIndex(s => s === currentStepId); - return stepIdx <= currentStepIdx; + return stepId === currentStepId || isStepFollowedBy(stepId, currentStepId); }; const createSteps = (stepsOrder) => { @@ -224,8 +242,10 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim setIsFormValid(false); } - // Reset the applied partitioning when going back from review page - if (prevStep.prevId === "installation-review") { + // Reset the applied partitioning when going back from a step after creating partitioning to a step + // before creating partitioning. + if ((prevStep.prevId === "accounts" || isStepFollowedBy("accounts", prevStep.prevId)) && + isStepFollowedBy(newStep.id, "accounts")) { setIsFormDisabled(true); resetPartitioning() .then( @@ -262,6 +282,7 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim stepsOrder={stepsOrder} storageEncryption={storageEncryption} storageScenarioId={storageScenarioId} + accounts={accounts} />} hideClose mainAriaLabel={`${title} content`} @@ -288,6 +309,7 @@ const Footer = ({ stepsOrder, storageEncryption, storageScenarioId, + accounts, }) => { const [nextWaitsConfirmation, setNextWaitsConfirmation] = useState(false); const [quitWaitsConfirmation, setQuitWaitsConfirmation] = useState(false); @@ -338,6 +360,9 @@ const Footer = ({ setStepNotification(); }, }); + } else if (activeStep.id === "accounts") { + setUsers(accountsToDbusUsers(accounts)); + onNext(); } else { onNext(); } diff --git a/ui/webui/src/components/Password.jsx b/ui/webui/src/components/Password.jsx new file mode 100644 index 00000000000..e8690e5a8e6 --- /dev/null +++ b/ui/webui/src/components/Password.jsx @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2023 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with This program; If not, see . + */ + +import cockpit from "cockpit"; +import React, { useState, useEffect, useMemo } from "react"; +import { debounce } from "throttle-debounce"; + +import { + Button, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + InputGroup, + InputGroupItem, + TextInput, +} from "@patternfly/react-core"; + +// eslint-disable-next-line camelcase +import { password_quality } from "cockpit-components-password.jsx"; +import { + ExclamationCircleIcon, + ExclamationTriangleIcon, + CheckCircleIcon, + EyeIcon, + EyeSlashIcon +} from "@patternfly/react-icons"; + +const _ = cockpit.gettext; + +export const ruleLength = { + id: "length", + text: (policy) => cockpit.format(_("Must be at least $0 characters"), policy["min-length"].v), + check: (policy, password) => password.length >= policy["min-length"].v, + isError: true, +}; + +/* Calculate the password quality levels based on the password policy + * 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 = [{ + id: "weak", + label: _("Weak"), + variant: "error", + icon: , + lower_bound: 0, + higher_bound: minQualility - 1, + valid: !isStrict, + }]; + + if (minQualility <= 69) { + levels.push({ + id: "medium", + label: _("Medium"), + variant: "warning", + icon: , + lower_bound: minQualility, + higher_bound: 69, + valid: true, + }); + } + + levels.push({ + id: "strong", + label: _("Strong"), + variant: "success", + icon: , + lower_bound: Math.max(70, minQualility), + higher_bound: 100, + valid: true, + }); + + return levels; +}; + +const getRuleResults = (rules, policy, password) => { + return rules.map(rule => { + return { + id: rule.id, + text: rule.text(policy, password), + isSatisfied: password.length > 0 ? rule.check(policy, password) : null, + isError: rule.isError + }; + }); +}; + +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 ( + + + {level.label} + + + ); + } +}; + +export const PasswordFormFields = ({ + idPrefix, + policy, + initialPassword, + passwordLabel, + onChange, + initialConfirmPassword, + confirmPasswordLabel, + onConfirmChange, + rules, + setIsValid, +}) => { + const [passwordHidden, setPasswordHidden] = useState(true); + const [confirmHidden, setConfirmHidden] = useState(true); + const [_password, _setPassword] = useState(initialPassword); + const [_confirmPassword, _setConfirmPassword] = useState(initialConfirmPassword); + const [password, setPassword] = useState(initialPassword); + const [confirmPassword, setConfirmPassword] = useState(initialConfirmPassword); + const [passwordStrength, setPasswordStrength] = useState(""); + + useEffect(() => { + debounce(300, () => { setPassword(_password); onChange(_password) })(); + }, [_password, onChange]); + + useEffect(() => { + debounce(300, () => { setConfirmPassword(_confirmPassword); onConfirmChange(_confirmPassword) })(); + }, [_confirmPassword, onConfirmChange]); + + const ruleResults = useMemo(() => { + return getRuleResults(rules, policy, password); + }, [policy, password, rules]); + + const ruleConfirmMatches = useMemo(() => { + return password.length > 0 ? password === confirmPassword : null; + }, [password, confirmPassword]); + + 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 ( + + {rule.text} + + ); + }); + + 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(password, strengthLevels); + setPasswordStrength(_passwordStrength); + }; + updatePasswordStrength(); + }, [password, strengthLevels]); + + useEffect(() => { + setIsValid( + rulesSatisfied(ruleResults) && + ruleConfirmMatches && + isValidStrength(passwordStrength, strengthLevels) + ); + }, [setIsValid, ruleResults, ruleConfirmMatches, passwordStrength, strengthLevels]); + + return ( + <> + + + + _setPassword(val)} + id={idPrefix + "-password-field"} + /> + + + + + + + + {ruleHelperItems} + + + + + + _setConfirmPassword(val)} + id={idPrefix + "-password-confirm-field"} + /> + + + + + + + + + {_("Passphrases must match")} + + + + + + ); +}; + +const getPasswordStrength = async (password, strengthLevels) => { + // In case of unacceptable password just return 0 + 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; +}; diff --git a/ui/webui/src/components/app.jsx b/ui/webui/src/components/app.jsx index ea6939cdb90..564d4d46e7d 100644 --- a/ui/webui/src/components/app.jsx +++ b/ui/webui/src/components/app.jsx @@ -36,6 +36,7 @@ import { StorageClient, initDataStorage, startEventMonitorStorage } from "../api import { PayloadsClient } from "../apis/payloads"; import { RuntimeClient, initDataRuntime, startEventMonitorRuntime } from "../apis/runtime"; import { NetworkClient, initDataNetwork, startEventMonitorNetwork } from "../apis/network.js"; +import { UsersClient } from "../apis/users"; import { setCriticalErrorAction } from "../actions/miscellaneous-actions.js"; @@ -78,6 +79,7 @@ export const Application = () => { new RuntimeClient(address), new BossClient(address), new NetworkClient(address), + new UsersClient(address), ]; clients.forEach(c => c.init()); diff --git a/ui/webui/src/components/storage/DiskEncryption.jsx b/ui/webui/src/components/storage/DiskEncryption.jsx index 04ad47f0f50..786427c714a 100644 --- a/ui/webui/src/components/storage/DiskEncryption.jsx +++ b/ui/webui/src/components/storage/DiskEncryption.jsx @@ -16,238 +16,38 @@ */ import cockpit from "cockpit"; -import React, { useState, useEffect, useMemo } from "react"; -import { debounce } from "throttle-debounce"; +import React, { useState, useEffect } from "react"; import { - Button, Checkbox, EmptyState, + EmptyStateHeader, EmptyStateIcon, + EmptyStateFooter, Form, - FormGroup, - FormHelperText, - HelperText, - HelperTextItem, - InputGroup, Spinner, - TextInput, TextContent, TextVariants, - Text, EmptyStateHeader, EmptyStateFooter, InputGroupItem, + Text, } from "@patternfly/react-core"; -// eslint-disable-next-line camelcase -import { password_quality } from "cockpit-components-password.jsx"; -import EyeIcon from "@patternfly/react-icons/dist/esm/icons/eye-icon"; -import EyeSlashIcon from "@patternfly/react-icons/dist/esm/icons/eye-slash-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 CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; - import "./DiskEncryption.scss"; -const _ = cockpit.gettext; - -/* Calculate the password quality levels based on the password policy - * 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 = [{ - id: "weak", - label: _("Weak"), - variant: "error", - icon: , - lower_bound: 0, - higher_bound: minQualility - 1, - valid: !isStrict, - }]; - - if (minQualility <= 69) { - levels.push({ - id: "medium", - label: _("Medium"), - variant: "warning", - icon: , - lower_bound: minQualility, - higher_bound: 69, - valid: true, - }); - } +import { PasswordFormFields, ruleLength } from "../Password.jsx"; - levels.push({ - id: "strong", - label: _("Strong"), - variant: "success", - icon: , - lower_bound: Math.max(70, minQualility), - higher_bound: 100, - valid: true, - }); +const _ = cockpit.gettext; - return levels; +const ruleAscii = { + id: "ascii", + text: (policy) => _("The passphrase you have provided contains non-ASCII characters. You may not be able to switch between keyboard layouts when typing it."), + check: (policy, password) => password.length > 0 && /^[\x20-\x7F]*$/.test(password), + isError: false, }; export function getStorageEncryptionState (password = "", confirmPassword = "", encrypt = false) { return { password, confirmPassword, encrypt }; } -const passwordStrengthLabel = (idPrefix, strength, strengthLevels) => { - const level = strengthLevels.filter(l => l.id === strength)[0]; - if (level) { - return ( - - - {level.label} - - - ); - } -}; - -// TODO create strengthLevels object with methods passed to the component ? -const PasswordFormFields = ({ - idPrefix, - policy, - password, - passwordLabel, - onChange, - passwordConfirm, - passwordConfirmLabel, - onConfirmChange, - passwordStrength, - ruleLength, - ruleConfirmMatches, - ruleAscii, - strengthLevels, -}) => { - const [passwordHidden, setPasswordHidden] = useState(true); - const [confirmHidden, setConfirmHidden] = useState(true); - const [_password, _setPassword] = useState(password); - const [_passwordConfirm, _setPasswordConfirm] = useState(passwordConfirm); - - useEffect(() => { - debounce(300, () => onChange(_password))(); - }, [_password, onChange]); - - useEffect(() => { - debounce(300, () => onConfirmChange(_passwordConfirm))(); - }, [_passwordConfirm, onConfirmChange]); - - return ( - <> - - - - _setPassword(val)} - id={idPrefix + "-password-field"} - /> - - - - - - - - - {cockpit.format(_("Must be at least $0 characters"), policy["min-length"].v)} - - {ruleAscii && - - {_("The passphrase you have provided contains non-ASCII characters. You may not be able to switch between keyboard layouts when typing it.")} - } - - - - - - _setPasswordConfirm(val)} - id={idPrefix + "-password-confirm-field"} - /> - - - - - - - - - {_("Passphrases must match")} - - - - - - ); -}; - -const getPasswordStrength = async (password, strengthLevels) => { - // In case of unacceptable password just return 0 - 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; -}; - -const getRuleLength = (password, minLength) => { - let ruleState = "indeterminate"; - if (password.length > 0 && password.length < minLength) { - ruleState = "error"; - } else if (password.length >= minLength) { - ruleState = "success"; - } - return ruleState; -}; - -const getRuleConfirmMatches = (password, confirm) => (password.length > 0 ? (password === confirm ? "success" : "error") : "indeterminate"); - const CheckDisksSpinner = ( {_("Checking storage configuration")}} icon={} headingLevel="h4" /> @@ -271,26 +71,9 @@ export const DiskEncryption = ({ }) => { const [password, setPassword] = useState(storageEncryption.password); const [confirmPassword, setConfirmPassword] = useState(storageEncryption.confirmPassword); - const [passwordStrength, setPasswordStrength] = useState(""); const isEncrypted = storageEncryption.encrypt; const luksPolicy = passwordPolicies.luks; - const ruleConfirmMatches = useMemo(() => { - return getRuleConfirmMatches(password, confirmPassword); - }, [password, confirmPassword]); - - const ruleLength = useMemo(() => { - return luksPolicy && getRuleLength(password, luksPolicy["min-length"].v); - }, [password, luksPolicy]); - - const ruleAscii = useMemo(() => { - return password.length > 0 && !/^[\x20-\x7F]*$/.test(password); - }, [password]); - - const strengthLevels = useMemo(() => { - return luksPolicy && getStrengthLevels(luksPolicy["min-quality"].v, luksPolicy["is-strict"].v); - }, [luksPolicy]); - const encryptedDevicesCheckbox = content => ( ); useEffect(() => { - if (!strengthLevels) { - return; - } - - const updatePasswordStrength = async () => { - const _passwordStrength = await getPasswordStrength(password, strengthLevels); - setPasswordStrength(_passwordStrength); - }; - updatePasswordStrength(); - }, [password, strengthLevels]); - - useEffect(() => { - const updateValidity = (isEncrypted) => { - const passphraseValid = ( - ruleLength === "success" && - ruleConfirmMatches === "success" && - isValidStrength(passwordStrength, strengthLevels) - ); - setIsFormValid(!isEncrypted || passphraseValid); - }; - - updateValidity(isEncrypted); - }, [setIsFormValid, isEncrypted, ruleConfirmMatches, ruleLength, passwordStrength, strengthLevels]); + setIsFormValid(!isEncrypted); + }, [setIsFormValid, isEncrypted]); useEffect(() => { setStorageEncryption(se => ({ ...se, password })); diff --git a/ui/webui/src/components/storage/DiskEncryption.scss b/ui/webui/src/components/storage/DiskEncryption.scss index bb1272cb4af..1d908619860 100644 --- a/ui/webui/src/components/storage/DiskEncryption.scss +++ b/ui/webui/src/components/storage/DiskEncryption.scss @@ -1,4 +1,4 @@ -// Span disk encryption password fields to take slightly more width than the default +// Limit the width of input fields #disk-encryption-encrypt-devices ~ .pf-v5-c-check__body { width: min(60ch, 100%); } diff --git a/ui/webui/src/components/users/Accounts.jsx b/ui/webui/src/components/users/Accounts.jsx new file mode 100644 index 00000000000..8ab65260aea --- /dev/null +++ b/ui/webui/src/components/users/Accounts.jsx @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2023 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with This program; If not, see . + */ + +import cockpit from "cockpit"; +import React, { useState, useEffect } from "react"; + +import { + Form, + FormGroup, + TextInput, + Title, +} from "@patternfly/react-core"; + +import "./Accounts.scss"; + +import { PasswordFormFields, ruleLength } from "../Password.jsx"; + +const _ = cockpit.gettext; + +export function getAccountsState ( + fullName = "", + userAccount = "", + password = "", + confirmPassword = "", +) { + return { + fullName, + userAccount, + password, + confirmPassword, + }; +} + +export const accountsToDbusUsers = (accounts) => { + return [{ + name: cockpit.variant("s", accounts.userAccount || ""), + gecos: cockpit.variant("s", accounts.fullName || ""), + password: cockpit.variant("s", accounts.password || ""), + "is-crypted": cockpit.variant("b", false), + groups: cockpit.variant("as", ["wheel"]), + }]; +}; + +const CreateAccount = ({ + idPrefix, + passwordPolicy, + setIsUserValid, + accounts, + setAccounts, +}) => { + const [fullName, setFullName] = useState(accounts.fullName); + const [userAccount, setUserAccount] = useState(accounts.userAccount); + const [password, setPassword] = useState(accounts.password); + const [confirmPassword, setConfirmPassword] = useState(accounts.confirmPassword); + const [isPasswordValid, setIsPasswordValid] = useState(false); + + useEffect(() => { + setIsUserValid(isPasswordValid && userAccount.length > 0); + }, [setIsUserValid, isPasswordValid, userAccount]); + + const passphraseForm = ( + + ); + + useEffect(() => { + setAccounts(ac => ({ ...ac, fullName, userAccount, password, confirmPassword })); + }, [setAccounts, fullName, userAccount, password, confirmPassword]); + + return ( +
+ + {_("Create account")} + + {_("This account will have administration priviledge with sudo.")} + + setFullName(val)} + /> + + + setUserAccount(val)} + /> + + {passphraseForm} +
+ ); +}; + +export const Accounts = ({ + idPrefix, + setIsFormValid, + passwordPolicies, + accounts, + setAccounts, +}) => { + const [isUserValid, setIsUserValid] = useState(); + useEffect(() => { + setIsFormValid(isUserValid); + }, [setIsFormValid, isUserValid]); + + return ( + <> + + + ); +}; + +export const getPageProps = ({ isBootIso }) => { + return ({ + id: "accounts", + label: _("Create Account"), + isHidden: !isBootIso, + title: null, + }); +}; diff --git a/ui/webui/src/components/users/Accounts.scss b/ui/webui/src/components/users/Accounts.scss new file mode 100644 index 00000000000..dcc4da3b88f --- /dev/null +++ b/ui/webui/src/components/users/Accounts.scss @@ -0,0 +1,10 @@ +// Limit the width of input fields +#accounts-create-account { + width: min(60ch, 100%); +} + +// FIXME: Undo when fixed upstream: https://github.com/patternfly/patternfly/issues/6032 +#accounts-create-account .pf-v5-c-form__group-label.pf-m-info { + flex-direction: column; + align-items: flex-start; +} diff --git a/ui/webui/test/check-basic b/ui/webui/test/check-basic index 7056e69f27b..3b2f5021169 100755 --- a/ui/webui/test/check-basic +++ b/ui/webui/test/check-basic @@ -21,6 +21,7 @@ from installer import Installer from language import Language from review import Review from storage import Storage +from users import dbus_reset_users from testlib import nondestructive, test_main, wait, Error # pylint: disable=import-error from utils import pretend_live_iso, get_pretty_name @@ -44,6 +45,7 @@ class TestBasic(anacondalib.VirtInstallMachineCase): # Do not start the installation in non-destructive tests as this performs non revertible changes # with the pages basically empty of common elements (as those are provided by the top-level installer widget) # we at least iterate over them to check this works as expected + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # Ensure that the 'actual' UI process is running/ @@ -61,10 +63,12 @@ class TestBasic(anacondalib.VirtInstallMachineCase): # Test that clicking on current step does not break navigation i.click_step_on_sidebar() + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # Test going back steps = [ + i.steps.ACCOUNTS, i.steps.DISK_CONFIGURATION, i.steps.DISK_ENCRYPTION, i.steps.DISK_CONFIGURATION, @@ -93,7 +97,7 @@ class TestBasic(anacondalib.VirtInstallMachineCase): b.wait_visible(f"#installation-back-btn:not([aria-disabled={True}]") # For live media in the review screen language details should still be displayed - i.reach(i.steps.REVIEW) + i.reach(i.steps.REVIEW, hidden_steps=[i.steps.ACCOUNTS]) r.check_language("English (United States)") def testAboutModal(self): diff --git a/ui/webui/test/check-review b/ui/webui/test/check-review index e401e600bb2..7ec8392ea1d 100755 --- a/ui/webui/test/check-review +++ b/ui/webui/test/check-review @@ -22,6 +22,7 @@ from storage import Storage # pylint: disable=import-error from review import Review from language import Language from progress import Progress +from users import dbus_reset_users from utils import add_public_key from testlib import nondestructive, test_main # pylint: disable=import-error @@ -40,6 +41,8 @@ class TestReview(anacondalib.VirtInstallMachineCase): # After clicking 'Next' on the storage step, partitioning is done, thus changing the available space on the disk # Since this is a non-destructive test we need to make sure the we reset partitioning to how it was before the test started self.addCleanup(s.dbus_reset_partitioning) + + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # check language is shown @@ -71,6 +74,7 @@ class TestReview(anacondalib.VirtInstallMachineCase): i.open() + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) i.begin_installation(should_fail=True, confirm_erase=False) @@ -85,6 +89,7 @@ class TestReview(anacondalib.VirtInstallMachineCase): # Set language to Macedonian that is now in the installer translated languages # Go to review page after it l.dbus_set_language("mk_MK.UTF-8") + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # test the macedonian language selected diff --git a/ui/webui/test/check-storage b/ui/webui/test/check-storage index 7f110632b78..9cda4588eed 100755 --- a/ui/webui/test/check-storage +++ b/ui/webui/test/check-storage @@ -19,7 +19,9 @@ import anacondalib from installer import Installer from storage import Storage +from users import dbus_reset_users from review import Review +from password import Password from testlib import nondestructive, test_main # pylint: disable=import-error from storagelib import StorageHelpers # pylint: disable=import-error from utils import pretend_live_iso @@ -29,11 +31,9 @@ from utils import pretend_live_iso class TestStorage(anacondalib.VirtInstallMachineCase, StorageHelpers): efi = False - def set_valid_password(self, password="abcdefgh"): - s = Storage(self.browser, self.machine) - - s.set_password(password) - s.set_password_confirm(password) + def set_valid_password(self, password_ui, password="abcdefgh"): + password_ui.set_password(password) + password_ui.set_password_confirm(password) def testLocalStandardDisks(self): b = self.browser @@ -133,6 +133,7 @@ class TestStorage(anacondalib.VirtInstallMachineCase, StorageHelpers): b = self.browser i = Installer(b, self.machine) s = Storage(b, self.machine) + p = Password(b, s.encryption_id_prefix) i.open() # Language selection @@ -165,51 +166,51 @@ class TestStorage(anacondalib.VirtInstallMachineCase, StorageHelpers): ) # No password set - s.check_pw_rule("min-chars", "indeterminate") - s.check_pw_rule("match", "indeterminate") + p.check_pw_rule("length", "indeterminate") + p.check_pw_rule("match", "indeterminate") i.check_next_disabled() # Set pw which is too short - s.set_password("abcd") - s.check_pw_strength(None) + p.set_password("abcd") + p.check_pw_strength(None) i.check_next_disabled() - s.check_pw_rule("min-chars", "error") - s.check_pw_rule("match", "error") + p.check_pw_rule("length", "error") + p.check_pw_rule("match", "error") # Make the pw 8 chars long - s.set_password("efgh", append=True, value_check=False) + p.set_password("efgh", append=True, value_check=False) i.check_next_disabled() - s.check_password("abcdefgh") - s.check_pw_rule("min-chars", "success") - s.check_pw_rule("match", "error") - s.check_pw_strength("weak") + p.check_password("abcdefgh") + p.check_pw_rule("length", "success") + p.check_pw_rule("match", "error") + p.check_pw_strength("weak") # Non-ASCII password - s.set_password(8 * "š") - s.check_password(8 * "š") - s.check_pw_rule("min-chars", "success") - s.check_pw_rule("match", "error") - s.check_pw_rule("ascii", "warning") - s.check_pw_strength("weak") + p.set_password(8 * "š") + p.check_password(8 * "š") + p.check_pw_rule("length", "success") + p.check_pw_rule("match", "error") + p.check_pw_rule("ascii", "warning") + p.check_pw_strength("weak") # Valid ASCII password - s.set_password("abcdefgh") - s.check_password("abcdefgh") + p.set_password("abcdefgh") + p.check_password("abcdefgh") # Set the password confirm - s.set_password_confirm("abcdefg") - s.check_pw_rule("match", "error") - s.set_password_confirm("abcdefgh") - s.check_pw_rule("match", "success") - s.check_pw_rule("min-chars", "success") - s.check_pw_strength("weak") - s.check_password("abcdefgh") - s.check_password_confirm("abcdefgh") + p.set_password_confirm("abcdefg") + p.check_pw_rule("match", "error") + p.set_password_confirm("abcdefgh") + p.check_pw_rule("match", "success") + p.check_pw_rule("length", "success") + p.check_pw_strength("weak") + p.check_password("abcdefgh") + p.check_password_confirm("abcdefgh") i.check_next_disabled(disabled=False) # Check setting strong password - s.set_password("Rwce82ybF7dXtCzFumanchu!!!!!!!!") - s.check_pw_strength("strong") + p.set_password("Rwce82ybF7dXtCzFumanchu!!!!!!!!") + p.check_pw_strength("strong") # Test moving back after partitioning is applied, # the partitioning should be reset. @@ -217,6 +218,7 @@ class TestStorage(anacondalib.VirtInstallMachineCase, StorageHelpers): b = self.browser i = Installer(b, self.machine) s = Storage(b, self.machine) + p = Password(b, s.encryption_id_prefix) i.open() # Language selection @@ -243,13 +245,13 @@ class TestStorage(anacondalib.VirtInstallMachineCase, StorageHelpers): s.check_encryption_selected(encrypt) # Set valid password - self.set_valid_password() + self.set_valid_password(p) # Verify that the password is saved when moving forward and back i.next() i.back() - s.check_password("abcdefgh") - s.check_password_confirm("abcdefgh") + p.check_password("abcdefgh") + p.check_password_confirm("abcdefgh") i.back() # Storage Configuration @@ -277,6 +279,7 @@ class TestStorage(anacondalib.VirtInstallMachineCase, StorageHelpers): # Go to Review step i.open() + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # Read partitioning data after we went to Review step @@ -295,10 +298,11 @@ class TestStorage(anacondalib.VirtInstallMachineCase, StorageHelpers): for _ in range(10): s.dbus_create_partitioning("AUTOMATIC") - # Go back to the previous page and re-enter the review screen. + # Go back to the Disk Configuration page and re-enter the review screen. # This should create again a new partitioning object and apply it # no matter how many partitioning objects were created before i.back() + i.back() i.reach(i.steps.REVIEW) new_applied_partitioning = s.dbus_get_applied_partitioning() new_created_partitioning = s.dbus_get_created_partitioning() @@ -306,7 +310,7 @@ class TestStorage(anacondalib.VirtInstallMachineCase, StorageHelpers): self.assertEqual(len(created_partitioning) + 11, len(new_created_partitioning)) self.assertEqual(new_applied_partitioning, new_created_partitioning[-1]) - # The applied partitioning should be reset when going back at any step from review page + # The applied partitioning should be reset also when going back to installation method i.click_step_on_sidebar(i.steps.INSTALLATION_METHOD) new_applied_partitioning = s.dbus_get_applied_partitioning() self.assertEqual(new_applied_partitioning, "") @@ -410,8 +414,10 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) "mount-point-mapping-table", ) + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) + # verify review screen r.check_disk(dev, "16.1 GB vda (0x1af4)") @@ -422,6 +428,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) applied_partitioning = s.dbus_get_applied_partitioning() # When adding a new partition a new partitioning should be created + i.back() i.back(previous_page=i.steps.CUSTOM_MOUNT_POINT) i.back() @@ -534,6 +541,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) s.select_mountpoint_row_mountpoint(3, "/home") s.check_mountpoint_row(3, "/home", f"{dev2}1", False, "xfs") + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # verify review screen @@ -620,6 +628,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) b.wait_in_text(f"{selector} .pf-v5-c-select__toggle-text", "luks") s.check_mountpoint_row_format_type(2, "xfs") + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) r.check_in_disk_row(dev1, 2, "luks-") @@ -666,6 +675,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) s.select_mountpoint_row_device(2, "encryptedraid") s.check_mountpoint_row_format_type(2, "xfs") + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) r.check_disk(dev, "16.1 GB vda (0x1af4)") @@ -718,6 +728,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) b.wait_in_text(f"{selector} .pf-v5-c-select__toggle-text", "luks") s.check_mountpoint_row_format_type(2, "xfs") + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) r.check_disk(dev, "16.1 GB vda (0x1af4)") @@ -778,6 +789,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) s.select_mountpoint_row_reformat(1) s.check_mountpoint_row_reformat(1, True) + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # verify review screen @@ -788,6 +800,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) r.check_disk_row(dev, 3, "home, 15.0 GB: mount, /home") r.check_disk_row_not_present(dev, f"unused") + i.back() i.back(previous_page=i.steps.CUSTOM_MOUNT_POINT) i.back() @@ -939,6 +952,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) s.select_mountpoint_row_reformat(1) s.check_mountpoint_row_reformat(1, True) + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # verify review screen @@ -950,6 +964,7 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers) r.check_disk_row(disk, 3, f"{vgname}-home, 8.12 GB: mount, /home") r.check_disk_row(disk, 4, f"{vgname}-swap, 902 MB: mount, swap") + i.back() i.back(previous_page=i.steps.CUSTOM_MOUNT_POINT) # remove the /home row and check that row 3 is now swap @@ -1027,6 +1042,7 @@ class TestStorageMountPointsEFI(anacondalib.VirtInstallMachineCase): s.select_mountpoint_row_device(3, f"{dev}3") s.check_mountpoint_row_format_type(3, "xfs") + self.addCleanup(lambda: dbus_reset_users(self.machine)) i.reach(i.steps.REVIEW) # verify review screen diff --git a/ui/webui/test/check-users b/ui/webui/test/check-users new file mode 100755 index 00000000000..5d6d3601db0 --- /dev/null +++ b/ui/webui/test/check-users @@ -0,0 +1,51 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2022 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; If not, see . + +import anacondalib + +from installer import Installer +from users import Users, CREATE_ACCOUNT_ID_PREFIX, create_user +from testlib import nondestructive, test_main # pylint: disable=import-error + +@nondestructive +class TestUsers(anacondalib.VirtInstallMachineCase): + def setUp(self): + super().setUp() + + def testBasic(self): + b = self.browser + i = Installer(b, self.machine) + u = Users(b, self.machine) + + i.open() + + i.reach(i.steps.ACCOUNTS) + create_user(b, self.machine) + b.assert_pixels( + "#app", + "users-step-basic", + ignore=["#betanag-icon"], + ) + i.reach(i.steps.REVIEW) + + users = u.dbus_get_users() + self.assertIn('"groups" as 1 "wheel"', users) + self.assertIn('"is-crypted" b false', users) + self.assertIn('"password" s "password"', users) + +if __name__ == '__main__': + test_main() diff --git a/ui/webui/test/end2end/storage_encryption.py b/ui/webui/test/end2end/storage_encryption.py index 703c163e765..caa69c62605 100755 --- a/ui/webui/test/end2end/storage_encryption.py +++ b/ui/webui/test/end2end/storage_encryption.py @@ -38,11 +38,11 @@ def configure_storage_encryption(self): self._storage.set_encryption_selected(True) self._storage.check_encryption_selected(True) - self._storage.set_password(self.luks_pass) - self._storage.check_password(self.luks_pass) - self._storage.set_password_confirm(self.luks_pass) - self._storage.check_password_confirm(self.luks_pass) - self._storage.check_pw_strength('weak') + self._storage_password.set_password(self.luks_pass) + self._storage_password.check_password(self.luks_pass) + self._storage_password.set_password_confirm(self.luks_pass) + self._storage_password.check_password_confirm(self.luks_pass) + self._storage_password.check_pw_strength('weak') @log_step() def post_install_step(self): diff --git a/ui/webui/test/end2end/wizard_navigation.py b/ui/webui/test/end2end/wizard_navigation.py index 0d24fd4d944..d1bbf239fcd 100755 --- a/ui/webui/test/end2end/wizard_navigation.py +++ b/ui/webui/test/end2end/wizard_navigation.py @@ -45,6 +45,8 @@ def test_wizard_navigation(self): self._installer.next() self.configure_storage_encryption() self._installer.next() + self.check_users_screen() + self._installer.next() # Finish installation self.check_review_screen() self._installer.begin_installation() diff --git a/ui/webui/test/helpers/end2end.py b/ui/webui/test/helpers/end2end.py index 02a2ccef42c..65008fbf8c5 100644 --- a/ui/webui/test/helpers/end2end.py +++ b/ui/webui/test/helpers/end2end.py @@ -30,6 +30,8 @@ from storage import Storage from review import Review from progress import Progress +from password import Password +from users import create_user from utils import add_public_key from testlib import MachineCase # pylint: disable=import-error from machine_install import VirtInstallMachine @@ -44,6 +46,7 @@ def setUp(self): self._installer = Installer(self.browser, self.machine) self._language = Language(self.browser, self.machine) self._storage = Storage(self.browser, self.machine) + self._storage_password = Password(self.browser, self._storage.encryption_id_prefix) self._review = Review(self.browser) self._progress = Progress(self.browser) self.__installation_finished = False @@ -75,6 +78,9 @@ def configure_storage_encryption(self): def check_review_screen(self): pass + def check_users_screen(self): + create_user(self.browser, self.machine) + def monitor_progress(self): self._progress.wait_done() @@ -101,6 +107,8 @@ def run_integration_test(self): self._installer.next() self.configure_storage_encryption() self._installer.next() + self.check_users_screen() + self._installer.next() self.check_review_screen() self._installer.begin_installation() self.monitor_progress() diff --git a/ui/webui/test/helpers/installer.py b/ui/webui/test/helpers/installer.py index 9867f21c230..7c0e57cc1db 100644 --- a/ui/webui/test/helpers/installer.py +++ b/ui/webui/test/helpers/installer.py @@ -18,6 +18,8 @@ from time import sleep from step_logger import log_step +from users import create_user + class InstallerSteps(UserDict): WELCOME = "installation-language" @@ -25,17 +27,22 @@ class InstallerSteps(UserDict): CUSTOM_MOUNT_POINT = "mount-point-mapping" DISK_CONFIGURATION = "disk-configuration" DISK_ENCRYPTION = "disk-encryption" + ACCOUNTS = "accounts" REVIEW = "installation-review" PROGRESS = "installation-progress" _steps_jump = {} _steps_jump[WELCOME] = INSTALLATION_METHOD _steps_jump[INSTALLATION_METHOD] = [DISK_ENCRYPTION, CUSTOM_MOUNT_POINT] - _steps_jump[DISK_ENCRYPTION] = REVIEW - _steps_jump[CUSTOM_MOUNT_POINT] = REVIEW + _steps_jump[DISK_ENCRYPTION] = ACCOUNTS + _steps_jump[CUSTOM_MOUNT_POINT] = ACCOUNTS + _steps_jump[ACCOUNTS] = REVIEW _steps_jump[REVIEW] = PROGRESS _steps_jump[PROGRESS] = [] + _steps_callbacks = {} + _steps_callbacks[ACCOUNTS] = create_user + class Installer(): def __init__(self, browser, machine): self.browser = browser @@ -58,7 +65,8 @@ def begin_installation(self, should_fail=False, confirm_erase=True): else: self.wait_current_page(self.steps._steps_jump[current_page]) - def reach(self, target_page): + def reach(self, target_page, hidden_steps=None): + hidden_steps = hidden_steps or [] path = [] prev_pages = [target_page] current_page = self.get_current_page() @@ -70,7 +78,10 @@ def reach(self, target_page): while self.get_current_page() != target_page: next_page = path.pop() - self.next(next_page=next_page) + if next_page not in hidden_steps: + self.next(next_page=next_page) + if next_page in self.steps._steps_callbacks: + self.steps._steps_callbacks[next_page](self.browser, self.machine) @log_step() def next(self, should_fail=False, next_page=""): diff --git a/ui/webui/test/helpers/password.py b/ui/webui/test/helpers/password.py new file mode 100644 index 00000000000..55324bfc67e --- /dev/null +++ b/ui/webui/test/helpers/password.py @@ -0,0 +1,74 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2022 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; If not, see . + +import os +import sys + +HELPERS_DIR = os.path.dirname(__file__) +sys.path.append(HELPERS_DIR) + +from step_logger import log_step + +class Password(): + def __init__(self, browser, id_prefix): + self.browser = browser + self.id_prefix = id_prefix + + @log_step(snapshot_before=True) + def check_pw_rule(self, rule, value): + sel = f"#{self.id_prefix}-password-rule-" + rule + cls_value = "pf-m-" + value + self.browser.wait_visible(sel) + self.browser.wait_attr_contains(sel, "class", cls_value) + + @log_step(snapshot_before=True) + def set_password(self, password, append=False, value_check=True): + sel = f"#{self.id_prefix}-password-field" + self.browser.set_input_text(sel, password, append=append, value_check=value_check) + + @log_step(snapshot_before=True) + def check_password(self, password): + sel = f"#{self.id_prefix}-password-field" + self.browser.wait_val(sel, password) + + @log_step(snapshot_before=True) + def set_password_confirm(self, password): + sel = f"#{self.id_prefix}-password-confirm-field" + self.browser.set_input_text(sel, password) + + @log_step(snapshot_before=True) + def check_password_confirm(self, password): + sel = f"#{self.id_prefix}-password-confirm-field" + self.browser.wait_val(sel, password) + + @log_step(snapshot_before=True) + def check_pw_strength(self, strength): + sel = f"#{self.id_prefix}-password-strength-label" + + if strength is None: + self.browser.wait_not_present(sel) + return + + variant = "" + if strength == "weak": + variant = "error" + elif strength == "medium": + variant = "warning" + elif strength == "strong": + variant = "success" + + self.browser.wait_attr_contains(sel, "class", "pf-m-" + variant) diff --git a/ui/webui/test/helpers/storage.py b/ui/webui/test/helpers/storage.py index 2274b7fa990..604998fe8cd 100644 --- a/ui/webui/test/helpers/storage.py +++ b/ui/webui/test/helpers/storage.py @@ -117,13 +117,15 @@ def check_disk_visible(self, disk, visible=True): class StorageEncryption(): + encryption_id_prefix = "disk-encryption" + def __init__(self, browser, machine): self.browser = browser self.machine = machine @log_step(snapshot_before=True) def check_encryption_selected(self, selected): - sel = "#disk-encryption-encrypt-devices" + sel = f"#{self.encryption_id_prefix}-encrypt-devices" if selected: self.browser.wait_visible(sel + ':checked') else: @@ -131,54 +133,9 @@ def check_encryption_selected(self, selected): @log_step(snapshot_before=True) def set_encryption_selected(self, selected): - sel = "#disk-encryption-encrypt-devices" + sel = f"#{self.encryption_id_prefix}-encrypt-devices" self.browser.set_checked(sel, selected) - @log_step(snapshot_before=True) - def check_pw_rule(self, rule, value): - sel = "#disk-encryption-password-rule-" + rule - cls_value = "pf-m-" + value - self.browser.wait_visible(sel) - self.browser.wait_attr_contains(sel, "class", cls_value) - - @log_step(snapshot_before=True) - def set_password(self, password, append=False, value_check=True): - sel = "#disk-encryption-password-field" - self.browser.set_input_text(sel, password, append=append, value_check=value_check) - - @log_step(snapshot_before=True) - def check_password(self, password): - sel = "#disk-encryption-password-field" - self.browser.wait_val(sel, password) - - @log_step(snapshot_before=True) - def set_password_confirm(self, password): - sel = "#disk-encryption-password-confirm-field" - self.browser.set_input_text(sel, password) - - @log_step(snapshot_before=True) - def check_password_confirm(self, password): - sel = "#disk-encryption-password-confirm-field" - self.browser.wait_val(sel, password) - - @log_step(snapshot_before=True) - def check_pw_strength(self, strength): - sel = "#disk-encryption-password-strength-label" - - if strength is None: - self.browser.wait_not_present(sel) - return - - variant = "" - if strength == "weak": - variant = "error" - elif strength == "medium": - variant = "warning" - elif strength == "strong": - variant = "success" - - self.browser.wait_attr_contains(sel, "class", "pf-m-" + variant) - class StorageUtils(StorageDestination): def __init__(self, browser, machine): diff --git a/ui/webui/test/helpers/users.py b/ui/webui/test/helpers/users.py new file mode 100644 index 00000000000..e6a53f2dd32 --- /dev/null +++ b/ui/webui/test/helpers/users.py @@ -0,0 +1,80 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2022 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; If not, see . + +import os +import sys + +HELPERS_DIR = os.path.dirname(__file__) +sys.path.append(HELPERS_DIR) + +from password import Password +from step_logger import log_step + + +USERS_SERVICE = "org.fedoraproject.Anaconda.Modules.Users" +USERS_INTERFACE = USERS_SERVICE +USERS_OBJECT_PATH = "/org/fedoraproject/Anaconda/Modules/Users" + +CREATE_ACCOUNT_ID_PREFIX = "accounts-create-account" + + +class UsersDBus(): + def __init__(self, machine): + self.machine = machine + self._bus_address = self.machine.execute("cat /run/anaconda/bus.address") + + def dbus_get_users(self): + ret = self.machine.execute(f'busctl --address="{self._bus_address}" \ + get-property \ + {USERS_SERVICE} \ + {USERS_OBJECT_PATH} \ + {USERS_INTERFACE} Users') + + return ret + + def dbus_clear_users(self): + self.machine.execute(f'busctl --address="{self._bus_address}" \ + set-property \ + {USERS_SERVICE} \ + {USERS_OBJECT_PATH} \ + {USERS_INTERFACE} Users aa{{sv}} 0') + + +class Users(UsersDBus): + def __init__(self, browser, machine): + self.browser = browser + + UsersDBus.__init__(self, machine) + + @log_step(snapshot_before=True) + def set_user_account(self, user_account, append=False, value_check=True): + sel = "#accounts-create-account-user-account" + self.browser.set_input_text(sel, user_account, append=append, value_check=value_check) + + +def create_user(browser, machine): + p = Password(browser, CREATE_ACCOUNT_ID_PREFIX) + u = Users(browser, machine) + + password = "password" + p.set_password(password) + p.set_password_confirm(password) + u.set_user_account("tester") + + +def dbus_reset_users(machine): + UsersDBus(machine).dbus_clear_users() diff --git a/ui/webui/test/reference b/ui/webui/test/reference index e05382c968a..926f5923b79 160000 --- a/ui/webui/test/reference +++ b/ui/webui/test/reference @@ -1 +1 @@ -Subproject commit e05382c968a80a76c717eca43882e12f982bff22 +Subproject commit 926f5923b7994a2858fb0b104936989ad60b5bd4