Skip to content

Commit

Permalink
Merge pull request #750 from balsa-asanovic/wifi-password-show-button
Browse files Browse the repository at this point in the history
Add PasswordInput component with visibility button
  • Loading branch information
dgdavid committed Sep 20, 2023
2 parents 2d42842 + 6f12cf0 commit 0c12b1c
Show file tree
Hide file tree
Showing 11 changed files with 200 additions and 26 deletions.
5 changes: 5 additions & 0 deletions web/package/cockpit-agama.changes
@@ -1,3 +1,8 @@
-------------------------------------------------------------------
Tue Sep 19 19:11:12 UTC 2023 - Balsa Asanovic <balsaasanovic95@gmail.com>

- Allow users to show password values (gh#openSUSE/agama#750).

-------------------------------------------------------------------
Tue Sep 19 11:18:05 UTC 2023 - José Iván López González <jlopez@suse.com>

Expand Down
5 changes: 5 additions & 0 deletions web/src/assets/styles/patternfly-overrides.scss
Expand Up @@ -165,3 +165,8 @@ table td > .pf-c-empty-state {
outline: none;
box-shadow: 0 0 0 1px var(--focus-color);
}

// Center icon in the visibility button of password input form fields
.password-toggler span.pf-c-button__icon {
display: flex;
}
5 changes: 5 additions & 0 deletions web/src/assets/styles/utilities.scss
Expand Up @@ -44,6 +44,11 @@
height: 14px;
}

.icon-size-15 {
width: 15px;
height: 15px;
}

.icon-size-16 {
width: 16px;
height: 16px;
Expand Down
18 changes: 7 additions & 11 deletions web/src/components/core/PasswordAndConfirmationInput.jsx
Expand Up @@ -20,13 +20,11 @@
*/

import React, { useState } from "react";
import {
FormGroup,
TextInput
} from "@patternfly/react-core";
import { FormGroup } from "@patternfly/react-core";
import { PasswordInput } from "~/components/core";
import { _ } from "~/i18n";

const PasswordAndConfirmationInput = ({ value, onChange, onValidation, isDisabled, split = false }) => {
const PasswordAndConfirmationInput = ({ value, onChange, onValidation, isDisabled }) => {
const [confirmation, setConfirmation] = useState(value || "");
const [error, setError] = useState("");

Expand Down Expand Up @@ -54,12 +52,11 @@ const PasswordAndConfirmationInput = ({ value, onChange, onValidation, isDisable
};

return (
<div className={split ? "split" : "stack"}>
<>
<FormGroup fieldId="password" label={_("Password")}>
<TextInput
<PasswordInput
id="password"
name="password"
type="password"
aria-label={_("User password")}
value={value}
isDisabled={isDisabled}
Expand All @@ -73,10 +70,9 @@ const PasswordAndConfirmationInput = ({ value, onChange, onValidation, isDisable
helperTextInvalid={error}
validated={error === "" ? "default" : "error"}
>
<TextInput
<PasswordInput
id="passwordConfirmation"
name="passwordConfirmation"
type="password"
aria-label={_("User password confirmation")}
value={confirmation}
isDisabled={isDisabled}
Expand All @@ -85,7 +81,7 @@ const PasswordAndConfirmationInput = ({ value, onChange, onValidation, isDisable
validated={error === "" ? "default" : "error"}
/>
</FormGroup>
</div>
</>
);
};

Expand Down
67 changes: 67 additions & 0 deletions web/src/components/core/PasswordInput.jsx
@@ -0,0 +1,67 @@
/*
* Copyright (c) [2023] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of version 2 of the GNU General Public License as published
* by the Free Software Foundation.
*
* 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 General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

/**
* Renders a password input field and a toggle button that can be used to reveal
* and hide the password
* @component
*
* @param {string} id - the identifier for the field.
* @param {Object} props - props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput},
* except `type` that will be ignored.
*/
import React, { useState } from "react";
import {
Button,
InputGroup,
TextInput
} from "@patternfly/react-core";
import { Icon } from "~/components/layout";
import { _ } from "~/i18n";

export default function PasswordInput({ id, ...props }) {
const [showPassword, setShowPassword] = useState(false);
const visibilityIconName = showPassword ? "visibility_off" : "visibility";

if (!id) {
const field = props.label || props["aria-label"] || props.name;
console.error(`The PasswordInput component must have an 'id' but it was not given for '${field}'`);
}

return (
<InputGroup>
<TextInput
{...props}
id={id}
type={showPassword ? 'text' : 'password'}
/>
<Button
id={`toggle-${id}-visibility`}
className="password-toggler"
aria-label={_("Password visibility button")}
variant="control"
onClick={() => setShowPassword((prev) => !prev)}
icon={<Icon name={visibilityIconName} size="15" />}
isDisabled={props.isDisabled}
/>
</InputGroup>
);
}
97 changes: 97 additions & 0 deletions web/src/components/core/PasswordInput.test.jsx
@@ -0,0 +1,97 @@
/*
* Copyright (c) [2023] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of version 2 of the GNU General Public License as published
* by the Free Software Foundation.
*
* 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 General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React, { useState } from "react";
import { screen } from "@testing-library/react";
import { plainRender } from "~/test-utils";
import userEvent from "@testing-library/user-event";
import PasswordInput from "./PasswordInput";
import { _ } from "~/i18n";

describe("PasswordInput Component", () => {
it("renders a password input", () => {
plainRender(
<PasswordInput
id="password"
name="password"
aria-label={_("User password")}
/>
);

const inputField = screen.getByLabelText("User password");
expect(inputField).toHaveAttribute("type", "password");
});

it("allows revealing the password", async () => {
plainRender(
<PasswordInput
id="password"
name="password"
aria-label={_("User password")}
/>
);

const passwordInput = screen.getByLabelText("User password");
const button = screen.getByRole("button");

expect(passwordInput).toHaveAttribute("type", "password");
await userEvent.click(button);
expect(passwordInput).toHaveAttribute("type", "text");
});

it("applies autoFocus behavior correctly", () => {
plainRender(
<PasswordInput
autoFocus
id="password"
name="password"
aria-label={_("User password")}
/>
);

const inputField = screen.getByLabelText("User password");
expect(document.activeElement).toBe(inputField);
});

// Using a controlled component for testing the rendered result instead of testing if
// the given onChange callback is called. The former is more aligned with the
// React Testing Library principles, https://testing-library.com/docs/guiding-principles/
const PasswordInputTest = (props) => {
const [password, setPassword] = useState(null);

return (
<>
<PasswordInput {...props} onChange={setPassword} />
{password && <p>Password value updated!</p>}
</>
);
};

it("triggers onChange callback", async () => {
const { user } = plainRender(<PasswordInputTest id="test-password" aria-label="Test password" />);
const passwordInput = screen.getByLabelText("Test password");

expect(screen.queryByText("Password value updated!")).toBeNull();
await user.type(passwordInput, "secret");

screen.getByText("Password value updated!");
});
});
1 change: 1 addition & 0 deletions web/src/components/core/index.js
Expand Up @@ -53,3 +53,4 @@ export { default as Tip } from "./Tip";
export { default as ShowTerminalButton } from "./ShowTerminalButton";
export { default as NotificationMark } from "./NotificationMark";
export { default as NumericTextInput } from "./NumericTextInput";
export { default as PasswordInput } from "./PasswordInput";
4 changes: 4 additions & 0 deletions web/src/components/layout/Icon.jsx
Expand Up @@ -66,6 +66,8 @@ import Terminal from "@icons/terminal.svg?component";
import Translate from "@icons/translate.svg?component";
import Tune from "@icons/tune.svg?component";
import Warning from "@icons/warning.svg?component";
import Visibility from "@icons/visibility.svg?component";
import VisibilityOff from "@icons/visibility_off.svg?component";
import Wifi from "@icons/wifi.svg?component";
import WifiFind from "@icons/wifi_find.svg?component";

Expand Down Expand Up @@ -117,6 +119,8 @@ const icons = {
terminal: Terminal,
translate: Translate,
tune: Tune,
visibility: Visibility,
visibility_off: VisibilityOff,
warning: Warning,
wifi: Wifi,
wifi_find: WifiFind,
Expand Down
5 changes: 2 additions & 3 deletions web/src/components/network/WifiConnectionForm.jsx
Expand Up @@ -32,6 +32,7 @@ import {
} from "@patternfly/react-core";
import { useInstallerClient } from "~/context/installer";
import { _ } from "~/i18n";
import { PasswordInput } from "~/components/core";

/*
* FIXME: it should be moved to the SecurityProtocols enum that already exists or to a class based
Expand Down Expand Up @@ -118,13 +119,11 @@ export default function WifiConnectionForm({ network, onCancel, onSubmitCallback
{ security === "wpa-psk" &&
// TRANSLATORS: WiFi password
<FormGroup fieldId="password" label={_("WPA Password")}>
<TextInput
<PasswordInput
id="password"
name="password"
aria-label={_("Password")}
value={password}
label={_("Password")}
type="password"
onChange={setPassword}
/>
</FormGroup> }
Expand Down
9 changes: 4 additions & 5 deletions web/src/components/questions/LuksActivationQuestion.jsx
Expand Up @@ -20,9 +20,9 @@
*/

import React, { useState } from "react";
import { Alert, Form, FormGroup, Text, TextInput } from "@patternfly/react-core";
import { Alert, Form, FormGroup, Text } from "@patternfly/react-core";
import { Icon } from "~/components/layout";
import { Popup } from "~/components/core";
import { PasswordInput, Popup } from "~/components/core";
import { QuestionActions } from "~/components/questions";
import { _ } from "~/i18n";

Expand Down Expand Up @@ -65,13 +65,12 @@ export default function LuksActivationQuestion({ question, answerCallback }) {
{ question.text }
</Text>
<Form onSubmit={triggerDefaultAction}>
{/* TRANSLATORS: field label */}
{ /* TRANSLATORS: field label */ }
<FormGroup label={_("Encryption Password")} fieldId="luks-password">
<TextInput
<PasswordInput
autoFocus
id="luks-password"
value={password}
type="password"
onChange={setPassword}
/>
</FormGroup>
Expand Down
10 changes: 3 additions & 7 deletions web/src/components/storage/iscsi/AuthFields.jsx
Expand Up @@ -22,7 +22,7 @@
import React, { useEffect } from "react";
import { FormGroup, TextInput } from "@patternfly/react-core";

import { Fieldset } from "~/components/core";
import { Fieldset, PasswordInput } from "~/components/core";
import { Icon } from "~/components/layout";
import { _ } from "~/i18n";

Expand Down Expand Up @@ -97,13 +97,11 @@ export default function AuthFields({ data, onChange, onValidate }) {
helperTextInvalid={_("Incorrect password")}
validated={showPasswordError() ? "error" : "default"}
>
<TextInput
<PasswordInput
id="password"
name="password"
type="password"
aria-label={_("Password")}
value={data.password || ""}
label={_("Password")}
onChange={onPasswordChange}
validated={showPasswordError() ? "error" : "default"}
/>
Expand Down Expand Up @@ -134,13 +132,11 @@ export default function AuthFields({ data, onChange, onValidate }) {
helperTextInvalid={_("Incorrect password")}
validated={showReversePasswordError() ? "error" : "default"}
>
<TextInput
<PasswordInput
id="reversePassword"
name="reversePassword"
type="password"
aria-label={_("Target Password")}
value={data.reversePassword || ""}
label={_("Password")}
isDisabled={!isValidAuth()}
onChange={onReversePasswordChange}
validated={showReversePasswordError() ? "error" : "default"}
Expand Down

0 comments on commit 0c12b1c

Please sign in to comment.