diff --git a/web/.eslintrc.json b/web/.eslintrc.json index a81e223c56..555faae497 100644 --- a/web/.eslintrc.json +++ b/web/.eslintrc.json @@ -33,6 +33,7 @@ "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 }], "no-var": "error", "no-use-before-define": "off", + "@typescript-eslint/no-unused-vars": ["warn", { "ignoreRestSiblings": true }], "@typescript-eslint/no-use-before-define": "warn", "@typescript-eslint/ban-ts-comment": "off", "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }], diff --git a/web/package-lock.json b/web/package-lock.json index 58891baab5..11c1f9844b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,9 +7,9 @@ "name": "d-installer", "license": "LGPL-2.1", "dependencies": { - "@patternfly/patternfly": "4.192.1", - "@patternfly/react-core": "4.207.0", - "@patternfly/react-table": "^4.61.15", + "@patternfly/patternfly": "^4.219.2", + "@patternfly/react-core": "^4.261.0", + "@patternfly/react-table": "^4.111.33", "core-js": "^3.21.1", "eos-ds": "^5.0.0", "eos-icons-react": "^2.3.0", @@ -2568,63 +2568,63 @@ } }, "node_modules/@patternfly/patternfly": { - "version": "4.192.1", - "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.192.1.tgz", - "integrity": "sha512-eNJ3aI9mGfvwMtBwkI+CBJHPhZx1FoNN6QY36iYEvrEOIL5xuuKRDG2tbOzeucQOzNqZ1PO1Eoock5xTcCG86Q==" + "version": "4.219.2", + "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.219.2.tgz", + "integrity": "sha512-SZ+9ig6DVygwM6fpldYhRjoyr9KMJTit/ZMyWCUE7DfpTqEr69wQnOaGxfvNlH1UV6G7Z+ALZXeck3Dy1nb9Vw==" }, "node_modules/@patternfly/react-core": { - "version": "4.207.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.207.0.tgz", - "integrity": "sha512-1259BDhQzDI+0w0G7b2uWbdGU+BC+UWoJfkaz4RSVIUq5XU9E5cywR7103GgewFgdDEg6xnKLQ+jfPV7fITvmA==", - "dependencies": { - "@patternfly/react-icons": "^4.58.0", - "@patternfly/react-styles": "^4.57.0", - "@patternfly/react-tokens": "^4.59.0", - "focus-trap": "6.2.2", + "version": "4.261.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.261.0.tgz", + "integrity": "sha512-yCX7wZpJOabMPRxQXduJ1E0sWALAUu9kMEA3iuty8OpwWbwL9DDgyKuqAEOiDcRN0Ju/4k7U++e8+28JV10nXQ==", + "dependencies": { + "@patternfly/react-icons": "^4.92.10", + "@patternfly/react-styles": "^4.91.10", + "@patternfly/react-tokens": "^4.93.10", + "focus-trap": "6.9.2", "react-dropzone": "9.0.0", "tippy.js": "5.1.2", "tslib": "^2.0.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" } }, "node_modules/@patternfly/react-icons": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.58.0.tgz", - "integrity": "sha512-g1Kj7ztkSoA+PBZUxbon8o09WxyUvd236wKQeSfStXQnz5HJFwOY/YxKWure228R6Xbh21+mutx7ha29XFzIYQ==", + "version": "4.92.10", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.92.10.tgz", + "integrity": "sha512-vwCy7b+OyyuvLDSLqLUG2DkJZgMDogjld8tJTdAaG8HiEhC1sJPZac+5wD7AuS3ym/sQolS4vYtNiVDnMEORxA==", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" } }, "node_modules/@patternfly/react-styles": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.57.0.tgz", - "integrity": "sha512-QXFciMps6v2xmQ1iAw6Xb1KEcXvUG2h4kSCp2LekmaooCmvqjR576OWsT6j3ODCAC2wqbctXvYioPJUr+4KqdQ==" + "version": "4.91.10", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.91.10.tgz", + "integrity": "sha512-fAG4Vjp63ohiR92F4e/Gkw5q1DSSckHKqdnEF75KUpSSBORzYP0EKMpupSd6ItpQFJw3iWs3MJi3/KIAAfU1Jw==" }, "node_modules/@patternfly/react-table": { - "version": "4.71.16", - "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.71.16.tgz", - "integrity": "sha512-HF//Sk1nnF7phAOjglC8zndRfJ3/3oe8Bkxbufhp3lSFdAyzjed6WBdV2dNIrsNj1UeO8MRSlYCbbDEZZlrO2A==", - "dependencies": { - "@patternfly/react-core": "^4.202.16", - "@patternfly/react-icons": "^4.53.16", - "@patternfly/react-styles": "^4.52.16", - "@patternfly/react-tokens": "^4.54.16", + "version": "4.111.33", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.111.33.tgz", + "integrity": "sha512-h5feSvjaoPwirRkLQePYm4NWXoYRfH0F9/WCdgStp1JAiCRVUqH8jDEZORv3xuFfMeFh4Vuq+WPEJu0B4VM5BQ==", + "dependencies": { + "@patternfly/react-core": "^4.258.3", + "@patternfly/react-icons": "^4.92.10", + "@patternfly/react-styles": "^4.91.10", + "@patternfly/react-tokens": "^4.93.10", "lodash": "^4.17.19", "tslib": "^2.0.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" } }, "node_modules/@patternfly/react-tokens": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.59.0.tgz", - "integrity": "sha512-uqsKUs50qzxerl1fylzgjLMRReX5Xps8oLznT/Quis4mY/rKWCo1Sr16vPKfVOYk60QOxnePfK5H4a3Cqz8Yrw==" + "version": "4.93.10", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.93.10.tgz", + "integrity": "sha512-F+j1irDc9M6zvY6qNtDryhbpnHz3R8ymHRdGelNHQzPTIK88YSWEnT1c9iUI+uM/iuZol7sJmO5STtg2aPIDRQ==" }, "node_modules/@sinonjs/commons": { "version": "1.8.3", @@ -6719,11 +6719,11 @@ "dev": true }, "node_modules/focus-trap": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.2.2.tgz", - "integrity": "sha512-qWovH9+LGoKqREvJaTCzJyO0hphQYGz+ap5Hc4NqXHNhZBdxCi5uBPPcaOUw66fHmzXLVwvETLvFgpwPILqKpg==", + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.9.2.tgz", + "integrity": "sha512-gBEuXOPNOKPrLdZpMFUSTyIo1eT2NSZRrwZ9r/0Jqw5tmT3Yvxfmu8KBHw8xW2XQkw6E/JoG+OlEq7UDtSUNgw==", "dependencies": { - "tabbable": "^5.1.4" + "tabbable": "^5.3.2" } }, "node_modules/forever-agent": { @@ -12598,9 +12598,9 @@ "dev": true }, "node_modules/tabbable": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.2.1.tgz", - "integrity": "sha512-40pEZ2mhjaZzK0BnI+QGNjJO8UYx9pP5v7BGe17SORTO0OEuuaAwQTkAp8whcZvqon44wKFOikD+Al11K3JICQ==" + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz", + "integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==" }, "node_modules/table": { "version": "6.8.0", @@ -15493,52 +15493,52 @@ } }, "@patternfly/patternfly": { - "version": "4.192.1", - "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.192.1.tgz", - "integrity": "sha512-eNJ3aI9mGfvwMtBwkI+CBJHPhZx1FoNN6QY36iYEvrEOIL5xuuKRDG2tbOzeucQOzNqZ1PO1Eoock5xTcCG86Q==" + "version": "4.219.2", + "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.219.2.tgz", + "integrity": "sha512-SZ+9ig6DVygwM6fpldYhRjoyr9KMJTit/ZMyWCUE7DfpTqEr69wQnOaGxfvNlH1UV6G7Z+ALZXeck3Dy1nb9Vw==" }, "@patternfly/react-core": { - "version": "4.207.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.207.0.tgz", - "integrity": "sha512-1259BDhQzDI+0w0G7b2uWbdGU+BC+UWoJfkaz4RSVIUq5XU9E5cywR7103GgewFgdDEg6xnKLQ+jfPV7fITvmA==", - "requires": { - "@patternfly/react-icons": "^4.58.0", - "@patternfly/react-styles": "^4.57.0", - "@patternfly/react-tokens": "^4.59.0", - "focus-trap": "6.2.2", + "version": "4.261.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.261.0.tgz", + "integrity": "sha512-yCX7wZpJOabMPRxQXduJ1E0sWALAUu9kMEA3iuty8OpwWbwL9DDgyKuqAEOiDcRN0Ju/4k7U++e8+28JV10nXQ==", + "requires": { + "@patternfly/react-icons": "^4.92.10", + "@patternfly/react-styles": "^4.91.10", + "@patternfly/react-tokens": "^4.93.10", + "focus-trap": "6.9.2", "react-dropzone": "9.0.0", "tippy.js": "5.1.2", "tslib": "^2.0.0" } }, "@patternfly/react-icons": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.58.0.tgz", - "integrity": "sha512-g1Kj7ztkSoA+PBZUxbon8o09WxyUvd236wKQeSfStXQnz5HJFwOY/YxKWure228R6Xbh21+mutx7ha29XFzIYQ==", + "version": "4.92.10", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.92.10.tgz", + "integrity": "sha512-vwCy7b+OyyuvLDSLqLUG2DkJZgMDogjld8tJTdAaG8HiEhC1sJPZac+5wD7AuS3ym/sQolS4vYtNiVDnMEORxA==", "requires": {} }, "@patternfly/react-styles": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.57.0.tgz", - "integrity": "sha512-QXFciMps6v2xmQ1iAw6Xb1KEcXvUG2h4kSCp2LekmaooCmvqjR576OWsT6j3ODCAC2wqbctXvYioPJUr+4KqdQ==" + "version": "4.91.10", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.91.10.tgz", + "integrity": "sha512-fAG4Vjp63ohiR92F4e/Gkw5q1DSSckHKqdnEF75KUpSSBORzYP0EKMpupSd6ItpQFJw3iWs3MJi3/KIAAfU1Jw==" }, "@patternfly/react-table": { - "version": "4.71.16", - "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.71.16.tgz", - "integrity": "sha512-HF//Sk1nnF7phAOjglC8zndRfJ3/3oe8Bkxbufhp3lSFdAyzjed6WBdV2dNIrsNj1UeO8MRSlYCbbDEZZlrO2A==", - "requires": { - "@patternfly/react-core": "^4.202.16", - "@patternfly/react-icons": "^4.53.16", - "@patternfly/react-styles": "^4.52.16", - "@patternfly/react-tokens": "^4.54.16", + "version": "4.111.33", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.111.33.tgz", + "integrity": "sha512-h5feSvjaoPwirRkLQePYm4NWXoYRfH0F9/WCdgStp1JAiCRVUqH8jDEZORv3xuFfMeFh4Vuq+WPEJu0B4VM5BQ==", + "requires": { + "@patternfly/react-core": "^4.258.3", + "@patternfly/react-icons": "^4.92.10", + "@patternfly/react-styles": "^4.91.10", + "@patternfly/react-tokens": "^4.93.10", "lodash": "^4.17.19", "tslib": "^2.0.0" } }, "@patternfly/react-tokens": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.59.0.tgz", - "integrity": "sha512-uqsKUs50qzxerl1fylzgjLMRReX5Xps8oLznT/Quis4mY/rKWCo1Sr16vPKfVOYk60QOxnePfK5H4a3Cqz8Yrw==" + "version": "4.93.10", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.93.10.tgz", + "integrity": "sha512-F+j1irDc9M6zvY6qNtDryhbpnHz3R8ymHRdGelNHQzPTIK88YSWEnT1c9iUI+uM/iuZol7sJmO5STtg2aPIDRQ==" }, "@sinonjs/commons": { "version": "1.8.3", @@ -18623,11 +18623,11 @@ "dev": true }, "focus-trap": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.2.2.tgz", - "integrity": "sha512-qWovH9+LGoKqREvJaTCzJyO0hphQYGz+ap5Hc4NqXHNhZBdxCi5uBPPcaOUw66fHmzXLVwvETLvFgpwPILqKpg==", + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.9.2.tgz", + "integrity": "sha512-gBEuXOPNOKPrLdZpMFUSTyIo1eT2NSZRrwZ9r/0Jqw5tmT3Yvxfmu8KBHw8xW2XQkw6E/JoG+OlEq7UDtSUNgw==", "requires": { - "tabbable": "^5.1.4" + "tabbable": "^5.3.2" } }, "forever-agent": { @@ -22963,9 +22963,9 @@ "dev": true }, "tabbable": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.2.1.tgz", - "integrity": "sha512-40pEZ2mhjaZzK0BnI+QGNjJO8UYx9pP5v7BGe17SORTO0OEuuaAwQTkAp8whcZvqon44wKFOikD+Al11K3JICQ==" + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz", + "integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==" }, "table": { "version": "6.8.0", diff --git a/web/package.json b/web/package.json index 51ce2da235..6e48936c66 100644 --- a/web/package.json +++ b/web/package.json @@ -65,9 +65,9 @@ "webpack-cli": "^4.9.1" }, "dependencies": { - "@patternfly/patternfly": "4.192.1", - "@patternfly/react-core": "4.207.0", - "@patternfly/react-table": "^4.61.15", + "@patternfly/patternfly": "^4.219.2", + "@patternfly/react-core": "^4.261.0", + "@patternfly/react-table": "^4.111.33", "core-js": "^3.21.1", "eos-ds": "^5.0.0", "eos-icons-react": "^2.3.0", diff --git a/web/src/ConnectionsDataList.jsx b/web/src/ConnectionsDataList.jsx index 1a0a04d57a..76cc2b2617 100644 --- a/web/src/ConnectionsDataList.jsx +++ b/web/src/ConnectionsDataList.jsx @@ -35,7 +35,8 @@ import { EOS_WIFI as WifiIcon } from "eos-icons-react"; -import { ConnectionTypes, formatIp } from "./client/network"; +import { ConnectionTypes } from "./client/network"; +import { formatIp } from "./client/network/utils"; export default function ConnectionsDataList({ conns, onSelect }) { if (conns.length === 0) return null; @@ -58,7 +59,7 @@ export default function ConnectionsDataList({ conns, onSelect }) { }; const renderConnectionId = (connection, onClick) => { - if (typeof onClick !== "function") return connection.id; + if (typeof onClick !== "function") return connection.name || connection.id; return ( + setWifiSelectorOpen(false)} /> + ); } diff --git a/web/src/Network.test.jsx b/web/src/Network.test.jsx new file mode 100644 index 0000000000..564e19c28b --- /dev/null +++ b/web/src/Network.test.jsx @@ -0,0 +1,68 @@ +/* + * Copyright (c) [2022] 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 from "react"; +import { screen, within } from "@testing-library/react"; +import { installerRender } from "./test-utils"; +import Network from "./Network"; +import { createClient } from "./client"; + +jest.mock("./client"); +jest.mock("./NetworkWiredStatus", () => () => "Wired Connections"); +jest.mock("./NetworkWifiStatus", () => () => "WiFi Connections"); + +beforeEach(() => { + createClient.mockImplementation(() => { + return { + network: { + setUp: () => Promise.resolve(true), + activeConnections: () => [], + connections: () => Promise.resolve([]), + accessPoints: () => [], + onNetworkEvent: jest.fn() + } + }; + }); +}); + +describe("Network", () => { + it("shows a link to open the WiFi selector", async () => { + installerRender(); + await screen.findByRole("button", { name: "Connect to a Wi-Fi network" }); + }); + + it("renders a summary for wired and wifi connections", async () => { + installerRender(); + + await screen.findByText("Wired Connections"); + await screen.findByText("WiFi Connections"); + }); + + describe("when the user clicks on connect to a Wi-Fi", () => { + it("opens the WiFi selector dialog", async () => { + const { user } = installerRender(); + const link = await screen.findByRole("button", { name: "Connect to a Wi-Fi network" }); + await user.click(link); + const wifiDialog = await screen.findByRole("dialog"); + within(wifiDialog).getByText("Connect to a Wi-Fi network"); + }); + }); +}); diff --git a/web/src/NetworkWifiStatus.jsx b/web/src/NetworkWifiStatus.jsx index 79bf10511e..26b413dec4 100644 --- a/web/src/NetworkWifiStatus.jsx +++ b/web/src/NetworkWifiStatus.jsx @@ -19,10 +19,10 @@ * find current contact information at www.suse.com. */ -import React from "react"; -import { ConnectionState } from "./client/network"; - +import React, { useState } from "react"; +import IpSettingsForm from "./IpSettingsForm"; import ConnectionsDataList from "./ConnectionsDataList"; +import { useInstallerClient } from "./context/installer"; /** * D-Installer component to show status of wireless network connections @@ -32,9 +32,17 @@ import ConnectionsDataList from "./ConnectionsDataList"; * @param {import ("client/network").ActiveConnection[]} connections */ export default function NetworkWiFiStatus({ connections }) { - const conns = connections.filter(c => c.state === ConnectionState.ACTIVATED); + const client = useInstallerClient(); + const [connection, setConnection] = useState(null); + + const selectConnection = ({ id }) => { + client.network.getConnection(id).then(setConnection); + }; return ( - + <> + + { connection && setConnection(null)} /> } + ); } diff --git a/web/src/TargetIpsPopup.jsx b/web/src/TargetIpsPopup.jsx index 5a31bb67f6..453a19c8e6 100644 --- a/web/src/TargetIpsPopup.jsx +++ b/web/src/TargetIpsPopup.jsx @@ -25,69 +25,50 @@ import Popup from "./Popup"; import { useInstallerClient } from "./context/installer"; import { useCancellablePromise } from "./utils"; -import { formatIp } from "./client/network"; +import { formatIp } from "./client/network/utils"; export default function TargetIpsPopup() { const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); - const [connections, setConnections] = useState([]); - const [hostname, setHostname] = useState(""); + const [addresses, setAddresses] = useState([]); + const [initialized, setInitialized] = useState(false); + const [hostname, setHostname] = useState(); const [isOpen, setIsOpen] = useState(false); useEffect(() => { - cancellablePromise(client.network.config()).then(config => { - setConnections(config.connections); - setHostname(config.hostname); - }); + cancellablePromise(client.network.setUp()).then(() => setInitialized(true)); }, [client.network, cancellablePromise]); useEffect(() => { - const onConnectionAdded = addedConnection => { - setConnections(conns => [...conns, addedConnection]); - }; - - return client.network.listen("connectionAdded", onConnectionAdded); - }, [client.network]); + if (!initialized) return; - useEffect(() => { - const onConnectionRemoved = id => { - setConnections(conns => conns.filter(c => c.id !== id)); + const refreshState = () => { + setAddresses(client.network.addresses()); + setHostname(client.network.hostname()); }; - return client.network.listen("connectionRemoved", onConnectionRemoved); - }, [client.network]); - - useEffect(() => { - const onConnectionUpdated = updatedConnection => { - setConnections(conns => { - const newConnections = conns.filter(c => c.id !== updatedConnection.id); - return [...newConnections, updatedConnection]; - }); - }; - - return client.network.listen("connectionUpdated", onConnectionUpdated); - }, [client.network]); - - if (connections.length === 0) return null; - - const ips = connections.flatMap(conn => conn.addresses.map(formatIp)); - const [firstIp] = ips; + refreshState(); + return client.network.onNetworkEvent(() => { + refreshState(); + }); + }, [client.network, initialized]); - if (ips.length === 0) return null; + if (addresses.length === 0) return null; + const [firstIp] = addresses; const open = () => setIsOpen(true); const close = () => setIsOpen(false); return ( <> - - + - {ips.map(ip => ( - {ip} + {addresses.map((ip, index) => ( + {formatIp(ip)} ))} diff --git a/web/src/TargetIpsPopup.test.jsx b/web/src/TargetIpsPopup.test.jsx index 26ed3aa510..079b59f7ad 100644 --- a/web/src/TargetIpsPopup.test.jsx +++ b/web/src/TargetIpsPopup.test.jsx @@ -29,29 +29,25 @@ import TargetIpsPopup from "./TargetIpsPopup"; jest.mock("./client"); -const conn0 = { - id: "7a9470b5-aa0e-4e20-b48e-3eee105543e9", - addresses: [ - { address: "1.2.3.4", prefix: 24 }, - { address: "5.6.7.8", prefix: 16 }, - ], -}; +const addresses = [ + { address: "1.2.3.4", prefix: 24 }, + { address: "5.6.7.8", prefix: 16 }, +]; +const addressFn = jest.fn().mockReturnValue(addresses); +const hostnameFn = jest.fn().mockReturnValue("example.net"); describe("TargetIpsPopup", () => { let callbacks; - const hostname = "example.net"; - beforeEach(() => { - callbacks = {}; - const listenFn = (event, cb) => { callbacks[event] = cb }; + callbacks = []; + const onNetworkEventFn = (cb) => { callbacks.push(cb) }; createClient.mockImplementation(() => { return { network: { - listen: listenFn, - config: () => Promise.resolve({ - connections: [conn0], - hostname - }), + onNetworkEvent: onNetworkEventFn, + addresses: addressFn, + hostname: hostnameFn, + setUp: jest.fn().mockResolvedValue() } }; }); @@ -65,7 +61,7 @@ describe("TargetIpsPopup", () => { const dialog = await screen.findByRole("dialog"); - within(dialog).getByText(/Ip Addresses/); + within(dialog).getByText(/IP Addresses/); within(dialog).getByText("5.6.7.8/16"); const closeButton = within(dialog).getByRole("button", { name: /Close/i }); @@ -76,33 +72,15 @@ describe("TargetIpsPopup", () => { }); }); - it("updates the IP if the connection changes", async () => { - installerRender(); - await screen.findByRole("button", { name: /1.2.3.4\/24 \(example.net\)/i }); - const updatedConn = { - ...conn0, - addresses: [{ address: "5.6.7.8", prefix: 24 }] - }; - - act(() => { - callbacks.connectionUpdated(updatedConn); - }); - await screen.findByRole("button", { name: /5.6.7.8\/24 \(example.net\)/i }); - }); - - it("updates the IP if the connection is replaced", async () => { + it("updates address and hostname if they change", async () => { installerRender(); await screen.findByRole("button", { name: /1.2.3.4\/24 \(example.net\)/i }); - const conn1 = { - ...conn0, - id: "2f1b1c0d-c835-479d-ae7d-e828bb4a75fa", - addresses: [{ address: "5.6.7.8", prefix: 24 }] - }; + addressFn.mockReturnValue([{ address: "5.6.7.8", prefix: 24 }]); + hostnameFn.mockReturnValue("localhost.localdomain"); act(() => { - callbacks.connectionAdded(conn1); - callbacks.connectionRemoved(conn0.id); + callbacks.forEach(cb => cb()); }); - await screen.findByRole("button", { name: /5.6.7.8\/24 \(example.net\)/i }); + await screen.findByRole("button", { name: /5.6.7.8\/24 \(localhost.localdomain\)/i }); }); }); diff --git a/web/src/WifiConnectionForm.jsx b/web/src/WifiConnectionForm.jsx new file mode 100644 index 0000000000..b7173e6588 --- /dev/null +++ b/web/src/WifiConnectionForm.jsx @@ -0,0 +1,133 @@ +/* + * Copyright (c) [2022] 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, { useEffect, useRef, useState } from "react"; +import { + ActionGroup, + Alert, + Button, + Form, + FormGroup, + FormSelect, + FormSelectOption, + TextInput +} from "@patternfly/react-core"; +import { useInstallerClient } from "./context/installer"; + +/* +* FIXME: it should be moved to the SecurityProtocols enum that already exists or to a class based +* enum pattern in the network_manager adapter. +*/ +const security_options = [ + { value: "", label: "None" }, + { value: "wpa-psk", label: "WPA & WPA2 Personal" } +]; + +const selectorOptions = security_options.map(security => ( + +)); + +const securityFrom = (supported) => { + if (supported.includes("WPA2")) + return "wpa-psk"; + if (supported.includes("WPA1")) + return "wpa-psk"; + return ""; +}; + +export default function WifiConnectionForm({ network, onCancel, onSubmitCallback }) { + const client = useInstallerClient(); + const formRef = useRef(); + const [error, setError] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [ssid, setSsid] = useState(network?.ssid || ""); + const [password, setPassword] = useState(network?.password || ""); + const [security, setSecurity] = useState(securityFrom(network?.security || [])); + const hidden = network?.hidden || false; + + useEffect(() => { + setTimeout(() => { formRef.current?.scrollIntoView({ behavior: "smooth" }) }, 200); + }, []); + + const accept = async e => { + e.preventDefault(); + setError(false); + setIsConnecting(true); + + if (typeof onSubmitCallback === "function") { + onSubmitCallback({ ssid, password, hidden, security: [security] }); + } + + client.network.addAndConnectTo(ssid, { security, password, hidden }) + .catch(() => setError(true)) + .finally(() => setIsConnecting(false)); + }; + + return ( +
+ { error && + +

Please, review provided settings and try again.

+
} + + { network?.hidden && + + + } + + + + {selectorOptions} + + + { security === "wpa-psk" && + + + } + + + + +
+ ); +} diff --git a/web/src/WifiHiddenNetworkForm.jsx b/web/src/WifiHiddenNetworkForm.jsx new file mode 100644 index 0000000000..b55a1c5666 --- /dev/null +++ b/web/src/WifiHiddenNetworkForm.jsx @@ -0,0 +1,77 @@ +/* + * Copyright (c) [2022] 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 from "react"; + +import { + Button, + Card, + CardBody, + Split, + SplitItem, +} from "@patternfly/react-core"; + +import { classNames } from "./utils"; +import Center from "./Center"; +import WifiConnectionForm from "./WifiConnectionForm"; + +/** + * Component to render a form for connecting to a hidden Wi-Fi Network + * + * @param {object} props - component props + * @param {object} props.network - a basic network object + * @param {boolean} props.visible - whether the form should be displayed + * @param {function} props.beforeDisplaying - callback to trigger before displaying the form + * @param {function} props.beforeHiding - callback to trigger before hiding the form + */ +function HiddenNetworkForm({ network, visible, beforeDisplaying, beforeHiding, onSubmitCallback }) { + return ( + <> + + + + + { visible && + } + + + + + { !visible && +
+ +
} + + ); +} + +export default HiddenNetworkForm; diff --git a/web/src/WifiHiddenNetworkForm.test.jsx b/web/src/WifiHiddenNetworkForm.test.jsx new file mode 100644 index 0000000000..b07edf52c5 --- /dev/null +++ b/web/src/WifiHiddenNetworkForm.test.jsx @@ -0,0 +1,70 @@ +/* + * Copyright (c) [2022] 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 from "react"; + +import { screen } from "@testing-library/react"; +import { plainRender } from "./test-utils"; + +import WifiHiddenNetworkForm from "./WifiHiddenNetworkForm"; + +jest.mock("./WifiConnectionForm", () => () => "WifiConnectionForm mock"); + +describe("WifiHiddenNetworkForm", () => { + describe("when it is visible", () => { + it("renders the WifiConnectionForm", () => { + plainRender(); + + screen.getByText("WifiConnectionForm mock"); + }); + + it("does not render the link for connecting to a hidden network", () => { + plainRender(); + expect(screen.queryByText(/Connect to hidden network/i)).not.toBeInTheDocument(); + }); + }); + + describe("when it is not visible", () => { + it("does not render the WifiConnectionForm", () => { + plainRender(); + expect(screen.queryByText("WifiConnectionForm mock")).not.toBeInTheDocument(); + }); + + it("renders the link for connecting to a hidden network", () => { + plainRender(); + screen.findByText(/Connect to hidden network/i); + }); + + describe("and the user clicks on the opening link", () => { + it("triggers the beforeDisplaying callback", async () => { + const beforeDisplayingFn = jest.fn(); + const { user } = plainRender( + + ); + + const link = screen.getByRole("button", { name: "Connect to hidden network" }); + await user.click(link); + + expect(beforeDisplayingFn).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/web/src/WifiNetworkListItem.jsx b/web/src/WifiNetworkListItem.jsx new file mode 100644 index 0000000000..d77d900283 --- /dev/null +++ b/web/src/WifiNetworkListItem.jsx @@ -0,0 +1,138 @@ +/* + * Copyright (c) [2022] 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 from "react"; + +import { + Card, + CardBody, + Radio, + Spinner, + Split, + SplitItem, + Text +} from "@patternfly/react-core"; + +import { + EOS_LOCK as LockIcon, + EOS_SIGNAL_CELLULAR_ALT as SignalIcon +} from "eos-icons-react"; + +import { classNames } from "./utils"; +import { ConnectionState } from "./client/network/model"; + +import Center from "./Center"; +import WifiNetworkMenu from "./WifiNetworkMenu"; +import WifiConnectionForm from "./WifiConnectionForm"; + +const networkState = (state) => { + switch (state) { + case ConnectionState.ACTIVATING: + return 'Connecting'; + case ConnectionState.ACTIVATED: + return 'Connected'; + case ConnectionState.DEACTIVATING: + return 'Disconnecting'; + case ConnectionState.DEACTIVATED: + return 'Disconnected'; + default: + return ""; + } +}; + +const isStateChanging = (network) => { + const state = network.connection?.state; + return state === ConnectionState.ACTIVATING || state === ConnectionState.DEACTIVATING; +}; + +/** + * Component for displaying a Wi-Fi network within a NetowrkList + * + * @param {object} props - component props + * @param {object} props.networks - the ap/configured network to be displayed + * @param {boolean} [props.isSelected] - whether the network has been selected by the user + * @param {boolean} [props.isActive] - whether the network is currently active + * @param {function} props.onSelect - function to execute when the network is selected + * @param {function} props.onCancel - function to execute when the selection is cancelled + */ +function WifiNetworkListItem ({ network, isSelected, isActive, onSelect, onCancel }) { + // Do not wait until receive the next D-Bus network event to have the connection object available + // and display the spinner as soon as possible. I.e., renders it inmmediately when the user clicks + // on an already configured network. + const showSpinner = (isSelected && network.settings && !network.connection) || isStateChanging(network); + + return ( + + + + + + {network.security.join(", ")}{" "} + {network.strength} + + } + isChecked={isSelected || isActive || false} + onClick={onSelect} + /> + + +
+ {showSpinner && } +
+
+ +
+ + { showSpinner && !network.connection && "Connecting" } + { networkState(network.connection?.state)} + +
+
+ { network.settings && + +
+ +
+
} +
+ { isSelected && (!network.settings || network.settings.error) && + + + + + } +
+
+ ); +} + +export default WifiNetworkListItem; diff --git a/web/src/WifiNetworkListItem.test.jsx b/web/src/WifiNetworkListItem.test.jsx new file mode 100644 index 0000000000..d7987319f4 --- /dev/null +++ b/web/src/WifiNetworkListItem.test.jsx @@ -0,0 +1,130 @@ +/* + * Copyright (c) [2022] 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 from "react"; + +import { screen } from "@testing-library/react"; +import { installerRender } from "./test-utils"; + +import WifiNetworkListItem from "./WifiNetworkListItem"; + +jest.mock("./WifiConnectionForm", () => () => "WifiConnectionForm mock"); +jest.mock("./WifiNetworkMenu", () => () => "WifiNetworkMenu mock"); + +const onSelectCallback = jest.fn(); +const fakeNetwork = { + ssid: "Fake Wi-Fi AP", + security: ["WPA2"], + strenght: 86 +}; + +const fakeSettings = { + wireless: { + password: "notSecret" + } +}; + +describe("NetworkListItem", () => { + it("renders an input radio for selecting the network", async () => { + installerRender(); + + await screen.findByRole("radio", { name: fakeNetwork.ssid, checked: false }); + }); + + describe("when isSelected prop is true", () => { + it("renders network as selected", async () => { + installerRender(); + + const wrapper = await screen.findByRole("article"); + expect(wrapper.classList.contains("selection-list-checked-item")).toBe(true); + }); + + it("renders the input radio as checked", async () => { + installerRender(); + + await screen.findByRole("radio", { name: fakeNetwork.ssid, checked: true }); + }); + }); + + describe("when isActive prop is true", () => { + it("renders network as selected", async () => { + installerRender(); + + const wrapper = await screen.findByRole("article"); + expect(wrapper.classList.contains("selection-list-checked-item")).toBe(true); + }); + + it("renders the input radio as checked", async () => { + installerRender(); + + await screen.findByRole("radio", { name: fakeNetwork.ssid, checked: true }); + }); + }); + + describe("when given network already has settings", () => { + const network = { ...fakeNetwork, settings: { ...fakeSettings } }; + + it("renders the WifiNetworkMenu", async () => { + installerRender(); + await screen.findByText("WifiNetworkMenu mock"); + }); + + describe("and it is selected", () => { + it("does not render the WifiConnectionForm", async () => { + installerRender(); + expect(screen.queryByText("WifiConnectionForm mock")).not.toBeInTheDocument(); + }); + }); + }); + + describe("when given network does not have settings", () => { + it("does not render the WifiNetworkMenu", async () => { + installerRender(); + expect(screen.queryByText("WifiNetworkMenu mock")).not.toBeInTheDocument(); + }); + + describe("and it is selected", () => { + it("renders the WifiConnectionForm", async () => { + installerRender(); + await screen.findByText("WifiConnectionForm mock"); + }); + + it("renders network as focused", async () => { + installerRender(); + + const wrapper = await screen.findByRole("article"); + expect(wrapper.classList.contains("selection-list-focused-item")).toBe(true); + }); + }); + }); + + describe("when the user clicks on the input radio", () => { + it("triggers callback given into onSelect prop", async () => { + const { user } = installerRender( + + ); + const radio = await screen.findByRole("radio", { name: fakeNetwork.ssid }); + await user.click(radio); + + expect(onSelectCallback).toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/WifiNetworkMenu.jsx b/web/src/WifiNetworkMenu.jsx new file mode 100644 index 0000000000..07f7593811 --- /dev/null +++ b/web/src/WifiNetworkMenu.jsx @@ -0,0 +1,48 @@ +/* + * Copyright (c) [2022] 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 from "react"; +import { DropdownItem } from "@patternfly/react-core"; +import { EOS_DELETE as DeleteIcon } from "eos-icons-react"; +import KebabMenu from "./KebabMenu"; +import { useInstallerClient } from "./context/installer"; + +export default function WifiNetworkMenu({ settings, position = "right" }) { + const client = useInstallerClient(); + + return ( + client.network.deleteConnection(settings)} + icon={} + className="danger-action" + > + Forget network + + ]} + /> + ); +} diff --git a/web/src/WifiNetworksList.jsx b/web/src/WifiNetworksList.jsx new file mode 100644 index 0000000000..4c2f8eea2d --- /dev/null +++ b/web/src/WifiNetworksList.jsx @@ -0,0 +1,56 @@ +/* + * Copyright (c) [2022] 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 from "react"; + +import WifiNetworkListItem from "./WifiNetworkListItem"; + +/** + * Component for displaying a list of available Wi-Fi networks + * + * @param {object} props - component props + * @param {object[]} [props.networks=[]] - list of networks to show + * @param {object} [props.activeNetwork] - the active network + * @param {object} [props.selectedNetwork] - the selected network (not necessarily the same as active) + * @param {function} props.onSelectionCallback - the function to trigger when user selects a network + * @param {function} props.onCancelCallback - the function to trigger when user cancel dismiss before connecting to a network + */ +function WifiNetworksList({ networks = [], activeNetwork, selectedNetwork, onSelectionCallback, onCancelSelectionCallback }) { + return networks.map(n => { + const isSelected = n.ssid === selectedNetwork?.ssid; + const isActive = !selectedNetwork && n.ssid === activeNetwork?.ssid; + + return ( + { + if (!isSelected) onSelectionCallback(n); + }} + onCancel={onCancelSelectionCallback} + /> + ); + }); +} + +export default WifiNetworksList; diff --git a/web/src/WifiNetworksList.test.jsx b/web/src/WifiNetworksList.test.jsx new file mode 100644 index 0000000000..a1f64154a9 --- /dev/null +++ b/web/src/WifiNetworksList.test.jsx @@ -0,0 +1,84 @@ +/* + * Copyright (c) [2022] 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 from "react"; + +import { screen, waitFor } from "@testing-library/react"; +import { installerRender } from "./test-utils"; + +import WifiNetworksList from "./WifiNetworksList"; + +const onSelectionCallback = jest.fn(); + +const myNetwork = { + ssid: "My Wi-Fi Network", + security: ["WPA2"], + strengh: 85, + settings: { wireless: { hidden: false } } +}; + +const otherNetwork = { + ssid: "My Neighbour Network", + security: ["WPA2"], + strengh: 35 +}; + +const networksMock = [myNetwork, otherNetwork]; + +describe("WifiNetworksList", () => { + it("renders nothing when no networks are given", async () => { + const { container } = installerRender(, { usingLayout: false }); + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("displays networks information", async () => { + installerRender(); + + expect(screen.getByText("My Wi-Fi Network")).toBeInTheDocument(); + expect(screen.getByText("My Neighbour Network")).toBeInTheDocument(); + }); + + describe("when the user clicks on a not selected network", () => { + it("triggers the onSelectionCallback", async () => { + const { user } = installerRender( + + ); + + const radio = await screen.findByRole("radio", { name: "My Wi-Fi Network" }); + await user.click(radio); + + expect(onSelectionCallback).toHaveBeenCalled(); + }); + }); + + describe("when the user clicks on an already selected network", () => { + it("does not trigger the onSelectionCallback", async () => { + const { user } = installerRender( + + ); + + const radio = await screen.findByRole("radio", { name: "My Wi-Fi Network" }); + await user.click(radio); + + expect(onSelectionCallback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/WifiSelector.jsx b/web/src/WifiSelector.jsx new file mode 100644 index 0000000000..8fcf94f7b6 --- /dev/null +++ b/web/src/WifiSelector.jsx @@ -0,0 +1,163 @@ +/* + * Copyright (c) [2022] 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, { useEffect, useState } from "react"; + +import { useInstallerClient } from "./context/installer"; +import { NetworkEventTypes } from "./client/network"; + +import Popup from "./Popup"; +import WifiNetworksList from "./WifiNetworksList"; +import WifiHiddenNetworkForm from "./WifiHiddenNetworkForm"; + +const networksFromValues = (networks) => Object.values(networks).flat(); +const baseHiddenNetwork = { ssid: undefined, hidden: true }; + +function WifiSelector({ isOpen = false, onClose }) { + const client = useInstallerClient(); + const [networks, setNetworks] = useState([]); + const [connections, setConnections] = useState([]); + const [activeConnections, setActiveConnections] = useState(client.network.activeConnections()); + const [selectedNetwork, setSelectedNetwork] = useState(null); + const [activeNetwork, setActiveNetwork] = useState(null); + const [showHiddenForm, setShowHiddenForm] = useState(false); + + const switchSelectedNetwork = (network) => { + setShowHiddenForm(network === baseHiddenNetwork); + setSelectedNetwork(network); + }; + + useEffect(() => { + client.network.connections().then(setConnections); + }, [client.network]); + + useEffect(() => { + const loadNetworks = async () => { + const knownSsids = []; + + return client.network.accessPoints() + .sort((a, b) => b.strength - a.strength) + .reduce((networks, ap) => { + // Do not include networks without SSID + if (!ap.ssid || ap.ssid === "") return networks; + // Do not include "duplicates" + if (knownSsids.includes(ap.ssid)) return networks; + + const network = { + ...ap, + settings: connections.find(c => c.wireless?.ssid === ap.ssid), + connection: activeConnections.find(c => c.name === ap.ssid) + }; + + // Group networks + if (network.connection) { + networks.connected.push(network); + } else if (network.settings) { + networks.configured.push(network); + } else { + networks.others.push(network); + } + + knownSsids.push(network.ssid); + + return networks; + }, { connected: [], configured: [], others: [] }); + }; + + loadNetworks().then((data) => { + setNetworks(data); + setActiveNetwork(networksFromValues(data).find(d => d.connection)); + }); + }, [client.network, connections, activeConnections]); + + useEffect(() => { + return client.network.onNetworkEvent(({ type, payload }) => { + switch (type) { + case NetworkEventTypes.CONNECTION_ADDED: { + setConnections(conns => [...conns, payload]); + break; + } + + case NetworkEventTypes.CONNECTION_UPDATED: { + setConnections(conns => { + const newConnections = conns.filter(c => c.id !== payload.id); + return [...newConnections, payload]; + }); + break; + } + + case NetworkEventTypes.CONNECTION_REMOVED: { + setConnections(conns => conns.filter(c => c.path !== payload.path)); + break; + } + + case NetworkEventTypes.ACTIVE_CONNECTION_ADDED: { + setActiveConnections(conns => [...conns, payload]); + break; + } + + case NetworkEventTypes.ACTIVE_CONNECTION_UPDATED: { + setActiveConnections(conns => { + const newConnections = conns.filter(c => c.id !== payload.id); + return [...newConnections, payload]; + }); + break; + } + + case NetworkEventTypes.ACTIVE_CONNECTION_REMOVED: { + setActiveConnections(conns => conns.filter(c => c.id !== payload.id)); + if (selectedNetwork?.settings?.id === payload.id) switchSelectedNetwork(null); + break; + } + } + }); + }); + + return ( + + { + switchSelectedNetwork(network); + if (network.settings && !network.connection) { + client.network.connectTo(network.settings); + } + }} + onCancelSelectionCallback={() => switchSelectedNetwork(activeNetwork) } + /> + switchSelectedNetwork(baseHiddenNetwork)} + beforeHiding={() => switchSelectedNetwork(activeNetwork)} + onSubmitCallback={switchSelectedNetwork} + /> + + Close + + + ); +} + +export default WifiSelector; diff --git a/web/src/app.scss b/web/src/app.scss index e68a37a364..60f9b42212 100644 --- a/web/src/app.scss +++ b/web/src/app.scss @@ -170,6 +170,10 @@ p { margin-block-start: 1em; } +.collapsed { + height: 0; + overflow: hidden; +} .hidden { visibility: hidden; } @@ -220,6 +224,7 @@ button.remove-link:hover { color: var(--pf-c-button--m-danger--BackgroundColor); } +// Custom styles for summary warnings .warning-text { color: var(--pf-global--warning-color--200); font-size: calc(fonts.$size-base - 2px); @@ -240,3 +245,64 @@ button.hidden-popover-button { visibility: hidden; display: inline; } + +// Custom selection list +.selection-list-item { + transition: + font-size .15s ease-in-out, + font-weight .25s ease-in-out, + margin-block .15s ease-in-out, + box-shadow .35s ease-in-out; +} + +.selection-list-item .pf-c-card__body { + padding: 0; +} +.selection-list-item .pf-c-card__body .header { + border-block-end: 1px solid #eee; + padding: fonts.$size-base; +} + +.selection-list-item .pf-c-card__body .content { + padding: calc(fonts.$size-base * 2); +} + +.selection-list-focused-item { + margin-block: 20px; + box-shadow: 0 0 6px rgba(0,0,0,.16),0 6px 12px rgba(0,0,0,.32); +} + +.selection-list-focused-item .pf-c-radio input { + // Keep input vertically aligned with the label + margin-top: calc(var(--pf-c-radio__label--FontSize) * 0.3); +} + +.selection-list-checked-item .pf-c-radio label, +.selection-list-focused-item .pf-c-radio label { + font-size: calc(var(--pf-c-radio__label--FontSize) * 1.3); + font-weight: bold; +} + +.danger-action { + color: var(--pf-global--danger-color--200); + svg { + fill: var(--pf-global--danger-color--200); + } +} + +.wifi-network-menu button.pf-c-dropdown__toggle { + padding-right: 0; +} + +.keep-words { + word-break: keep-all; +} + +.wifi-network-menu{ + button.toggler { + padding-inline-start: 0; + svg { + vertical-align: middle; + } + } +} diff --git a/web/src/client/dbus.js b/web/src/client/dbus.js index af4e4a5fe1..1d07c44b4a 100644 --- a/web/src/client/dbus.js +++ b/web/src/client/dbus.js @@ -98,7 +98,11 @@ class DBusClient { * @return {Promise} DBusProxies object */ async proxies(iface, path_namespace, options) { - return this.client.proxies(iface, path_namespace, options); + const all = await this.client.proxies( + iface, path_namespace, { watch: true, ...options } + ); + await all.wait(); + return all; } /** diff --git a/web/src/client/dbus.test.js b/web/src/client/dbus.test.js index 704cebbcb6..5cce5f0848 100644 --- a/web/src/client/dbus.test.js +++ b/web/src/client/dbus.test.js @@ -30,6 +30,7 @@ const proxyObject = { const cockpitDBusClient = { proxy: jest.fn().mockReturnValue(proxyObject), + proxies: jest.fn().mockReturnValue(proxyObject), call: jest.fn().mockReturnValue(true) }; @@ -52,6 +53,17 @@ describe("DBusClient", () => { }); }); + describe("#proxies", () => { + it("returns a DBusProxies for the given iface and namespace", async () => { + const iface = "org.freedesktop.NetworkManager.Device"; + const path = "/org/freedesktop/NetworkManager/Device"; + const client = new DBusClient("org.opensuse.DInstaller"); + const proxies = await client.proxies(iface, path); + expect(cockpitDBusClient.proxies).toHaveBeenCalledWith(iface, path, { watch: true }); + expect(proxies).toBe(proxyObject); + }); + }); + describe("#call", () => { it("calls to the given D-Bus method", async () => { const client = new DBusClient("org.opensuse.DInstaller"); diff --git a/web/src/client/network.test.js b/web/src/client/network.test.js index 1f03cfa0c0..9fd51b511f 100644 --- a/web/src/client/network.test.js +++ b/web/src/client/network.test.js @@ -23,7 +23,7 @@ import { NetworkClient, ConnectionTypes, ConnectionState } from "./network"; -const conn = { +const active_conn = { id: "uuid-wired", name: "Wired connection 1", type: ConnectionTypes.ETHERNET, @@ -31,25 +31,48 @@ const conn = { addresses: [{ address: "192.168.122.1", prefix: 24 }] }; +const conn = { + id: "uuid-wired", + name: "Wired connection 1", + type: ConnectionTypes.ETHERNET, + addresses: [{ address: "192.168.122.1", prefix: 24 }] +}; + const adapter = { - activeConnections: jest.fn().mockResolvedValue([conn]), - hostname: jest.fn().mockResolvedValue("localhost"), + setUp: jest.fn(), + activeConnections: jest.fn().mockReturnValue([active_conn]), + connections: jest.fn().mockReturnValue([conn]), + hostname: jest.fn().mockReturnValue("localhost.localdomain"), subscribe: jest.fn(), getConnection: jest.fn(), - updateConnection: jest.fn() + addConnection: jest.fn(), + updateConnection: jest.fn(), + deleteConnection: jest.fn(), + accessPoints: jest.fn(), + connectTo: jest.fn(), + addAndConnectTo: jest.fn() }; describe("NetworkClient", () => { - describe("#config", () => { - it("returns an object containing the hostname, known IPv4 addresses, and active connections", async () => { + describe("#activeConnections", () => { + it("returns the list of active connections from the adapter", () => { const client = new NetworkClient(adapter); - const config = await client.config(); + const connections = client.activeConnections(); + expect(connections).toEqual([active_conn]); + }); + }); - expect(config.hostname).toEqual("localhost"); - expect(config.addresses).toEqual([ - { address: "192.168.122.1", prefix: 24 } - ]); - expect(config.connections).toEqual([conn]); + describe("#addresses", () => { + it("returns the list of addresses", () => { + const client = new NetworkClient(adapter); + expect(client.addresses()).toEqual([{ address: "192.168.122.1", prefix: 24 }]); + }); + }); + + describe("#hostname", () => { + it("returns the hostname from the adapter", () => { + const client = new NetworkClient(adapter); + expect(client.hostname()).toEqual("localhost.localdomain"); }); }); }); diff --git a/web/src/client/network/index.js b/web/src/client/network/index.js index 7a93decf7c..a30c2fc2a4 100644 --- a/web/src/client/network/index.js +++ b/web/src/client/network/index.js @@ -22,90 +22,40 @@ // @ts-check import { NetworkManagerAdapter } from "./network_manager"; - -const NetworkEventTypes = Object.freeze({ - ACTIVE_CONNECTION_ADDED: "active_connection_added", - ACTIVE_CONNECTION_UPDATED: "active_connection_updated", - ACTIVE_CONNECTION_REMOVED: "active_connection_removed" -}); +import { ConnectionTypes, ConnectionState } from "./model"; /** - * Enum for the active connection state values - * - * @readonly - * @enum { number } - * https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState + * @typedef {import("./model").Connection} Connection + * @typedef {import("./model").ActiveConnection} ActiveConnection + * @typedef {import("./model").IPAddress} IPAddress + * @typedef {import("./model").AccessPoint} AccessPoint */ -const ConnectionState = Object.freeze({ - UNKWOWN: 0, - ACTIVATING: 1, - ACTIVATED: 2, - DEACTIVATING: 3, - DEACTIVATED: 4 -}); -const ConnectionTypes = Object.freeze({ - ETHERNET: "802-3-ethernet", - WIFI: "802-11-wireless" +const NetworkEventTypes = Object.freeze({ + ACTIVE_CONNECTION_ADDED: "active_connection_added", + ACTIVE_CONNECTION_UPDATED: "active_connection_updated", + ACTIVE_CONNECTION_REMOVED: "active_connection_removed", + CONNECTION_ADDED: "connection_added", + CONNECTION_UPDATED: "connection_updated", + CONNECTION_REMOVED: "connection_removed" }); -/** - * @typedef {object} IPAddress - * @property {string} address - like "129.168.1.2" - * @property {string} prefix - like "16" - */ - -/** - * @typedef {object} ActiveConnection - * @property {string} id - * @property {string} name - * @property {string} type - * @property {number} state - * @property {IPAddress[]} addresses - */ - -/** - * @typedef {object} Connection - * @property {string} id - * @property {string} name - * @property {IPv4} ipv4 - */ - -/** - * @typedef {object} IPv4 - * @property {string} method - * @property {IPAddress[]} addresses - * @property {string[]} nameServers - * @property {IPAddress} gateway - */ - -/** @typedef {(conns: ActiveConnection[]) => void} ConnectionFn */ -/** @typedef {(conns: string[]) => void} ConnectionPathsFn */ - -/** - * @typedef {object} Handlers - * @property {ConnectionFn[]} connectionAdded - * @property {ConnectionFn[]} connectionRemoved - * @property {ConnectionFn[]} connectionUpdated - */ - /** * @typedef {object} NetworkAdapter - * @property {() => Promise} activeConnections + * @property {() => ActiveConnection[]} activeConnections + * @property {() => AccessPoint[]} accessPoints + * @property {() => Promise} connections * @property {(handler: (event: NetworkEvent) => void) => void} subscribe * @property {(id: string) => Promise} getConnection + * @property {(ssid: string, options: object) => boolean} addAndConnectTo + * @property {(connection: Connection) => boolean} connectTo + * @property {(connection: Connection) => Promise} addConnection * @property {(connection: Connection) => Promise} updateConnection - * @property {() => Promise} hostname + * @property {(connection: Connection) => void} deleteConnection + * @property {() => string} hostname + * @property {() => void} setUp */ -/** - * Returns given IP address in the X.X.X.X/YY format - * - * @param {IPAddress} addr - * @return {string} - */ -const formatIp = addr => `${addr.address}/${addr.prefix}`; - /** * Network event * @@ -114,6 +64,12 @@ const formatIp = addr => `${addr.address}/${addr.prefix}`; * @property {object} payload */ +/** + * Network event handler + * + * @typedef {(event: NetworkEvent) => void} NetworkEventFn + */ + /** * Network client */ @@ -126,83 +82,88 @@ o * NetworkManagerAdapter. this.adapter = adapter || new NetworkManagerAdapter(); /** @type {!boolean} */ this.subscribed = false; - /** @type {Handlers} */ - this.handlers = { - connectionAdded: [], - connectionRemoved: [], - connectionUpdated: [] - }; + this.setUpDone = false; + /** @type {NetworkEventFn[]} */ + this.handlers = []; } /** - * Returns IP config overview - addresses, connections and hostname - * @return {Promise<{addresses: IPAddress[], hostname: string, connections: ActiveConnection[]}>} + * Adds a callback to run when a network event happens (a connection is added, + * updated, removed, etc.). + * + * @param {NetworkEventFn} handler - Callback function + * @return {() => void} Function to remove the handler */ - async config() { - return { - connections: await this.adapter.activeConnections(), - addresses: await this.addresses(), - hostname: await this.adapter.hostname() + onNetworkEvent(handler) { + this.handlers.push(handler); + return () => { + const position = this.handlers.indexOf(handler); + if (position > -1) this.handlers.splice(position, 1); }; } /** - * Registers a callback to run when a given event happens + * Set up the client + */ + async setUp() { + if (this.setUpDone) return; + + return this.adapter.setUp(e => this.handlers.forEach(f => f(e))); + } + + /** + * Returns the active connections * - * @param {"connectionAdded" | "connectionUpdated" | "connectionRemoved"} eventType - event type - * @param {ConnectionFn} handler - the callback to be executed - * @return {function} a function to remove the callback + * @return {ActiveConnection[]} */ - listen(eventType, handler) { - if (!this.subscribed) { - // FIXME: when/where should we unsubscribe? - this.subscribe(); - } + activeConnections() { + return this.adapter.activeConnections(); + } - this.handlers[eventType].push(handler); - return () => { - this.handlers[eventType].filter(h => h !== handler); - }; + /** + * Returns the connection settings + * + * @return {Promise} + */ + connections() { + return this.adapter.connections(); } /** - * FIXME: improve this documentation - * Starts listening changes on active connections + * Returns the list of available wireless access points (AP) * - * @private - * @return {Promise} function to disable the callback + * @return {AccessPoint[]} */ - async subscribe() { - // TODO: refactor this method - this.subscribed = true; - - this.adapter.subscribe(({ type, payload }) => { - switch (type) { - case NetworkEventTypes.ACTIVE_CONNECTION_ADDED: { - this.handlers.connectionAdded.forEach(handler => handler(payload)); - break; - } - - case NetworkEventTypes.ACTIVE_CONNECTION_UPDATED: { - this.handlers.connectionUpdated.forEach(handler => handler(payload)); - break; - } - - case NetworkEventTypes.ACTIVE_CONNECTION_REMOVED: { - this.handlers.connectionRemoved.forEach(handler => handler(payload.path)); - break; - } - } - }); + accessPoints() { + return this.adapter.accessPoints(); } /** - * Returns the active connections + * Connects to given Wireless network * - * @returns {Promise} + * @param {Connection} connection - connection to be activated */ - async activeConnections() { - return this.adapter.activeConnections(); + async connectTo(connection) { + return this.adapter.connectTo(connection); + } + + /** + * Add the connection for the given Wireless network and activate it + * + * @param {string} ssid - Network id + * @param {object} options - connection options + */ + async addAndConnectTo(ssid, options) { + return this.adapter.addAndConnectTo(ssid, options); + } + + /** + * Adds a new connection + * + * @param {Connection} connection - Connection to add + */ + async addConnection(connection) { + return this.adapter.addConnection(connection); } /** @@ -226,6 +187,17 @@ o * NetworkManagerAdapter. return this.adapter.updateConnection(connection); } + /** + * Deletes the connection + * + * It uses the 'path' to match the connection in the backend. + * + * @param {Connection} connection - Connection to delete + */ + async deleteConnection(connection) { + return this.adapter.deleteConnection(connection); + } + /* * Returns list of IP addresses for all active NM connections * @@ -233,12 +205,21 @@ o * NetworkManagerAdapter. * @private * @return {Promise} */ - async addresses() { - const conns = await this.adapter.activeConnections(); + addresses() { + const conns = this.adapter.activeConnections(); return conns.flatMap(c => c.addresses); } + + /** + * Returns the computer's hostname + * + * @return {string} + */ + hostname() { + return this.adapter.hostname(); + } } export { - ConnectionState, ConnectionTypes, formatIp, NetworkClient, NetworkManagerAdapter, NetworkEventTypes + ConnectionState, ConnectionTypes, NetworkClient, NetworkManagerAdapter, NetworkEventTypes }; diff --git a/web/src/client/network/model.js b/web/src/client/network/model.js new file mode 100644 index 0000000000..b4688a8030 --- /dev/null +++ b/web/src/client/network/model.js @@ -0,0 +1,182 @@ +/* + * Copyright (c) [2022] 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. + */ + +// @ts-check + +/** + * Enum for the active connection state values + * + * @readonly + * @enum { number } + * https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState + */ +const ConnectionState = Object.freeze({ + UNKNOWN: 0, + ACTIVATING: 1, + ACTIVATED: 2, + DEACTIVATING: 3, + DEACTIVATED: 4 +}); + +/** + * Returns a human readable connection state + * + * @property {number} state + * @return {string} + */ +const connectionHumanState = (state) => { + const stateIndex = Object.values(ConnectionState).indexOf(state); + const stateKey = Object.keys(ConnectionState)[stateIndex]; + return stateKey.toLowerCase(); +}; + +const ConnectionTypes = Object.freeze({ + ETHERNET: "802-3-ethernet", + WIFI: "802-11-wireless" +}); + +const SecurityProtocols = Object.freeze({ + WEP: "WEP", + WPA: "WPA1", + RSN: "WPA2", + _8021X: "802.1X" +}); + +/** + * @typedef {object} IPAddress + * @property {string} address - like "129.168.1.2" + * @property {number|string} prefix - like "16" + */ + +/** + * @typedef {object} ActiveConnection + * @property {string} id + * @property {string} name + * @property {string} type + * @property {number} state + * @property {IPAddress[]} addresses + */ + +/** + * @typedef {object} Connection + * @property {string} id + * @property {string} name + * @property {IPv4} [ipv4] + * @property {Wireless} [wireless] + */ + +/** + * @typedef {object} Wireless + * @property {string} password + * @property {string} ssid + * @property {string} security + * @property {boolean} hidden + */ + +/** + * @typedef {object} IPv4 + * @property {string} method + * @property {IPAddress[]} addresses + * @property {string[]} nameServers + * @property {string} gateway + */ + +/** + * @typedef {object} AccessPoint + * @property {string} ssid + * @property {number} strength + * @property {string} hwAddress + * @property {string[]} security + */ + +/** + * Returns an IPv4 configuration object + * + * Defaults values can be overriden + * + * @private + * @param {object} props + * @param {string} [props.method] + * @param {IPAddress[]} [props.addresses] + * @param {string[]} [props.nameServers] + * @param {string} [props.gateway] + * @return {IPv4} + */ +const createIPv4 = ({ method, addresses, nameServers, gateway }) => { + return { + method: method || "auto", + addresses: addresses || [], + nameServers: nameServers || [], + gateway: gateway || "", + }; +}; + +/** + * Returns a connection object + * + * Defaults values can be overriden + * + * @param {object} options + * @param {string} [options.id] - Connection ID + * @param {string} [options.name] - Connection name + * @param {object} [options.ipv4] IPv4 Settings + * @param {object} [options.wireless] Wireless Settings + * @return {Connection} + */ +const createConnection = ({ id, name, ipv4, wireless }) => { + const connection = { + id, + name, + ipv4: createIPv4(ipv4 || {}), + }; + + if (wireless) connection.wireless = wireless; + + return connection; +}; + +/** + * Returns an access point object + * + * @param {object} options + * @param {string} options.ssid - Network SSID + * @param {string} options.hwAddress - AP hardware address + * @param {number} options.strength - Signal strength + * @param {string[]} [options.security] - Supported security protocols + * @return {AccessPoint} + */ +const createAccessPoint = ({ ssid, hwAddress, strength, security }) => ( + { + ssid, + hwAddress, + strength, + security: security || [] + } +); + +export { + createConnection, + createAccessPoint, + connectionHumanState, + ConnectionState, + ConnectionTypes, + SecurityProtocols +}; diff --git a/web/src/client/network/model.test.js b/web/src/client/network/model.test.js new file mode 100644 index 0000000000..1eef2f8cb5 --- /dev/null +++ b/web/src/client/network/model.test.js @@ -0,0 +1,85 @@ +/* + * Copyright (c) [2022] 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. + */ + +// @ts-check + +import { createConnection, createAccessPoint, connectionHumanState } from "./model"; + +describe("createConnection", () => { + it("creates a connection with the default values", () => { + const connection = createConnection({ name: "Wired connection" }); + expect(connection).toEqual({ + id: undefined, + name: "Wired connection", + ipv4: { + method: "auto", + addresses: [], + nameServers: [], + gateway: "" + } + }); + expect(connection.wireless).toBeUndefined(); + }); + + it("merges given properties", () => { + const addresses = [{ address: "192.168.0.1", prefix: 24 }]; + const connection = createConnection({ ipv4: { addresses, testing: 1 } }); + expect(connection.ipv4).toEqual({ + method: "auto", + addresses, + nameServers: [], + gateway: "" + }); + }); + + it("adds a wireless key when given", () => { + const wireless = { ssid: "MY_WIRELESS" }; + const connection = createConnection({ name: "Wireless 1", wireless }); + expect(connection.wireless).toEqual(wireless); + }); +}); + +describe("createAccessPoint", () => { + it("creates an AccessPoint using the given values", () => { + const ap = createAccessPoint({ + ssid: "WIFI1", + hwAddress: "11:22:33:44:55:66", + strength: 90, + }); + + expect(ap).toEqual({ + ssid: "WIFI1", + hwAddress: "11:22:33:44:55:66", + strength: 90, + security: [] + }); + }); +}); + +describe("connectionHumanState", () => { + it("returns a human readable connection state", () => { + expect(connectionHumanState(0)).toEqual("unknown"); + expect(connectionHumanState(1)).toEqual("activating"); + expect(connectionHumanState(2)).toEqual("activated"); + expect(connectionHumanState(3)).toEqual("deactivating"); + expect(connectionHumanState(4)).toEqual("deactivated"); + }); +}); diff --git a/web/src/client/network/network_manager.js b/web/src/client/network/network_manager.js index 2b6197af77..becd385419 100644 --- a/web/src/client/network/network_manager.js +++ b/web/src/client/network/network_manager.js @@ -24,14 +24,140 @@ import { DBusClient } from "../dbus"; import cockpit from "../../lib/cockpit"; import { intToIPString, stringToIPInt } from "./utils"; -import { NetworkEventTypes, ConnectionState } from "./index"; +import { NetworkEventTypes } from "./index"; +import { createAccessPoint, createConnection, SecurityProtocols } from "./model"; -const NM_SERVICE_NAME = "org.freedesktop.NetworkManager"; -const NM_IFACE = "org.freedesktop.NetworkManager"; -const NM_SETTINGS_IFACE = "org.freedesktop.NetworkManager.Settings"; -const NM_CONNECTION_IFACE = "org.freedesktop.NetworkManager.Settings.Connection"; -const NM_ACTIVE_CONNECTION_IFACE = "org.freedesktop.NetworkManager.Connection.Active"; -const NM_IP4CONFIG_IFACE = "org.freedesktop.NetworkManager.IP4Config"; +/** + * @typedef {import("./model").Connection} Connection + * @typedef {import("./model").ActiveConnection} ActiveConnection + * @typedef {import("./model").IPAddress} IPAddress + * @typedef {import("./model").AccessPoint} AccessPoint + * @typedef {import("./index").NetworkEventFn} NetworkEventFn + */ + +const SERVICE_NAME = "org.freedesktop.NetworkManager"; +const IFACE = "org.freedesktop.NetworkManager"; +const SETTINGS_IFACE = "org.freedesktop.NetworkManager.Settings"; +const CONNECTION_IFACE = "org.freedesktop.NetworkManager.Settings.Connection"; +const ACTIVE_CONNECTION_IFACE = "org.freedesktop.NetworkManager.Connection.Active"; +const ACTIVE_CONNECTION_NAMESPACE = "/org/freedesktop/NetworkManager/ActiveConnection"; +const IP4CONFIG_IFACE = "org.freedesktop.NetworkManager.IP4Config"; +const IP4CONFIG_NAMESPACE = "/org/freedesktop/NetworkManager/IP4Config"; +const ACCESS_POINT_IFACE = "org.freedesktop.NetworkManager.AccessPoint"; +const ACCESS_POINT_NAMESPACE = "/org/freedesktop/NetworkManager/AccessPoint"; +const SETTINGS_NAMESPACE = "/org/freedesktop/NetworkManager/Settings"; + +const ApFlags = Object.freeze({ + NONE: 0x00000000, + PRIVACY: 0x00000001, + WPS: 0x00000002, + WPS_PBC: 0x00000004, + WPS_PIN: 0x00000008 +}); + +const ApSecurityFlags = Object.freeze({ + NONE: 0x00000000, + PAIR_WEP40: 0x00000001, + PAIR_WEP104: 0x00000002, + PAIR_TKIP: 0x00000004, + PAIR_CCMP: 0x00000008, + GROUP_WEP40: 0x00000010, + GROUP_WEP104: 0x00000020, + GROUP_TKIP: 0x00000040, + GROUP_CCMP: 0x00000080, + KEY_MGMT_PSK: 0x00000100, + KEY_MGMT_8021_X: 0x00000200, +}); + +/** +* @param {number} flags - AP flags +* @param {number} wpa_flags - AP WPA1 flags +* @param {number} rsn_flags - AP WPA2 flags +* @return {string[]} security protocols supported +*/ +const securityFromFlags = (flags, wpa_flags, rsn_flags) => { + const security = []; + + if ((flags & ApFlags.PRIVACY) && (wpa_flags === 0) && (rsn_flags === 0)) + security.push(SecurityProtocols.WEP); + + if (wpa_flags > 0) + security.push(SecurityProtocols.WPA); + if (rsn_flags > 0) + security.push(SecurityProtocols.RSN); + if ((wpa_flags & ApSecurityFlags.KEY_MGMT_8021_X) || (rsn_flags & ApSecurityFlags.KEY_MGMT_8021_X)) + security.push(SecurityProtocols._8021X); + + return security; +}; + +/** + * @param {Connection} connection - Connection to convert + */ +const connectionToCockpit = (connection) => { + const { ipv4, wireless } = connection; + const settings = { + connection: { + id: cockpit.variant("s", connection.name) + }, + ipv4: { + "address-data": cockpit.variant("aa{sv}", ipv4.addresses.map(addr => ( + { + address: cockpit.variant("s", addr.address), + prefix: cockpit.variant("u", parseInt(addr.prefix.toString())) + } + ))), + dns: cockpit.variant("au", ipv4.nameServers.map(stringToIPInt)), + method: cockpit.variant("s", ipv4.method) + } + }; + + if (ipv4.gateway && connection.ipv4.addresses.length !== 0) { + settings.ipv4.gateway = cockpit.variant("s", ipv4.gateway); + } + + if (wireless) { + settings.connection.type = cockpit.variant("s", "802-11-wireless"); + settings["802-11-wireless"] = { + mode: cockpit.variant("s", "infrastructure"), + ssid: cockpit.variant("ay", cockpit.byte_array(wireless.ssid)), + hidden: cockpit.variant("b", !!wireless.hidden) + }; + + if (wireless.security) { + settings["802-11-wireless-security"] = { + "key-mgmt": cockpit.variant("s", wireless.security), + psk: cockpit.variant("s", wireless.password) + }; + } + } + + return settings; +}; + +/** + * It merges the information from a connection into a D-Bus settings object + * + * @param {object} settings - Settings from the GetSettings D-Bus method + * @param {Connection} connection - Connection containing the information to update + * @return {object} Object to be used with the UpdateConnection D-Bus method + */ +const mergeConnectionSettings = (settings, connection) => { + const { addresses, gateway, ...cleanIPv4 } = settings.ipv4 || {}; + const { connection: conn, ipv4 } = connectionToCockpit(connection); + + return { + ...settings, + connection: { + ...settings.connection, + ...conn + }, + ipv4: { + ...cleanIPv4, + ...ipv4 + } + }; +}; /** * NetworkClient adapter for NetworkManager @@ -44,49 +170,170 @@ class NetworkManagerAdapter { * @param {DBusClient} [dbusClient] - D-Bus client */ constructor(dbusClient) { - this.client = dbusClient || new DBusClient(NM_SERVICE_NAME); + this.client = dbusClient || new DBusClient(SERVICE_NAME); /** @type {{[k: string]: string}} */ this.connectionIds = {}; + this.proxies = { + accessPoints: {}, + activeConnections: {}, + ip4Configs: {}, + settings: null, + connections: {} + }; + this.eventsHandler = null; + } + + /** + * Builds proxies and starts listening to them + * + * @param {NetworkEventFn} handler - Events handler + */ + async setUp(handler) { + this.eventsHandler = handler; + this.proxies = { + accessPoints: await this.client.proxies(ACCESS_POINT_IFACE, ACCESS_POINT_NAMESPACE), + activeConnections: await this.client.proxies( + ACTIVE_CONNECTION_IFACE, ACTIVE_CONNECTION_NAMESPACE + ), + ip4Configs: await this.client.proxies(IP4CONFIG_IFACE, IP4CONFIG_NAMESPACE), + settings: await this.client.proxy(SETTINGS_IFACE), + connections: await this.client.proxies(CONNECTION_IFACE, SETTINGS_NAMESPACE) + }; + this.subscribeToEvents(); } /** * Returns the list of active connections * - * @return {Promise} + * @return {ActiveConnection[]} * @see https://developer-old.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.html */ - async activeConnections() { - const proxy = await this.client.proxy(NM_IFACE); - let connections = []; - const paths = await proxy.ActiveConnections; - for (const path of paths) { - connections = [...connections, await this.activeConnectionFromPath(path)]; - } - return connections; + activeConnections() { + return Object.values(this.proxies.activeConnections).map(proxy => { + return this.activeConnectionFromProxy(proxy); + }); } /** - * Returns the connection with the given ID + * Returns the list of configured connections * - * @param {string} id - Connection ID - * @return {Promise} + * @return {Promise} + * @see https://developer-old.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.html */ - async getConnection(id) { - const settingsProxy = await this.connectionSettingsObject(id); - const { connection, ipv4 } = await settingsProxy.GetSettings(); - return { + async connections() { + return await Promise.all(Object.values(this.proxies.connections).map(async proxy => { + return await this.connectionFromProxy(proxy); + })); + } + + /** + * Returns the list of available wireless access points (AP) + * + * @return {AccessPoint[]} + */ + accessPoints() { + return Object.values(this.proxies.accessPoints).map(ap => { + return createAccessPoint({ + ssid: window.atob(ap.Ssid), + hwAddress: ap.HwAddress, + strength: ap.Strength, + security: securityFromFlags(ap.Flags, ap.WpaFlags, ap.RsnFlags) + }); + }); + } + + /** + * Returns a Connection given the nm-settings given by DBUS + * + * @param {object} settings - connection options + * @return {Connection} + * + */ + connectionFromSettings(settings) { + const { connection, ipv4, "802-11-wireless": wireless, path } = settings; + const conn = { id: connection.uuid.v, name: connection.id.v, - ipv4: { + type: connection.type.v + }; + + if (path) conn.path = path; + + if (ipv4) { + conn.ipv4 = { addresses: ipv4["address-data"].v.map(({ address, prefix }) => { return { address: address.v, prefix: prefix.v }; }), // FIXME: handle different byte-order (little-endian vs big-endian) nameServers: ipv4.dns?.v.map(intToIPString) || [], method: ipv4.method.v, - gateway: ipv4.gateway?.v - } - }; + }; + if (ipv4.gateway?.v) conn.ipv4.gateway = ipv4.gateway.v; + } + + if (wireless) { + conn.wireless = { + ssid: window.atob(wireless.ssid.v), + hidden: wireless.hidden?.v || false + }; + } + + return conn; + } + + /** + * Returns the connection with the given ID + * + * @param {string} id - Connection ID + * @return {Promise} + */ + async getConnection(id) { + const settingsProxy = await this.connectionSettingsObject(id); + const settings = await settingsProxy.GetSettings(); + + return this.connectionFromSettings(settings); + } + + /** + * Connects to given Wireless network + * + * @param {object} settings - connection options + */ + async connectTo(settings) { + const settingsProxy = await this.connectionSettingsObject(settings.id); + await this.activateConnection(settingsProxy.path); + } + + /** + * Connects to given Wireless network + * + * @param {string} ssid - Network id + * @param {object} options - connection options + */ + async addAndConnectTo(ssid, options = {}) { + const wireless = { ssid }; + if (options.security) wireless.security = options.security; + if (options.password) wireless.password = options.password; + if (options.hidden) wireless.hidden = options.hidden; + + const connection = createConnection({ + name: ssid, + wireless + }); + + await this.addConnection(connection); + } + + /** + * Adds a new connection + * + * @param {Connection} connection - Connection to add + */ + async addConnection(connection) { + const proxy = await this.client.proxy(SETTINGS_IFACE); + const connCockpit = connectionToCockpit(connection); + const path = await proxy.AddConnection(connCockpit); + await this.activateConnection(path); } /** @@ -99,76 +346,61 @@ class NetworkManagerAdapter { async updateConnection(connection) { const settingsProxy = await this.connectionSettingsObject(connection.id); const settings = await settingsProxy.GetSettings(); - - delete settings.ipv4.addresses; - delete settings.ipv4["address-data"]; - delete settings.ipv4.gateway; - delete settings.ipv4.dns; - - const newSettings = { - ...settings, - ipv4: { - ...settings.ipv4, - "address-data": cockpit.variant("aa{sv}", connection.ipv4.addresses.map(addr => ( - { - address: cockpit.variant("s", addr.address), - prefix: cockpit.variant("u", parseInt(addr.prefix)) - } - )) - ), - dns: cockpit.variant("au", connection.ipv4.nameServers.map(stringToIPInt)), - method: cockpit.variant("s", connection.ipv4.method) - } - }; - - // FIXME: find a better way to add the gateway only if there are addresses. Otherwise, - // NetworkManager raises the following D-Bus error: "gateway cannot be set if there are - // no addresses configured". - if ((connection.ipv4.gateway) && (newSettings.ipv4["address-data"].v.length !== 0)) { - newSettings.ipv4.gateway = cockpit.variant("s", connection.ipv4.gateway); - } - + const newSettings = mergeConnectionSettings(settings, connection); await settingsProxy.Update(newSettings); await this.activateConnection(settingsProxy.path); } + /** + * Deletes the given connection + * + * @param {import("./index").Connection} connection - Connection to delete + */ + async deleteConnection(connection) { + const settingsProxy = await this.connectionSettingsObject(connection.id); + await settingsProxy.Delete(); + } + /** * Subscribes to network events * * Registers a handler for changes in /org/freedesktop/NetworkManager/ActiveConnection/*. * The handler recevies a NetworkEvent object. * - * @param {(event: import("./index").NetworkEvent) => void} handler - Event handler function + * @private */ - async subscribe(handler) { - const proxies = await this.client.proxies( - NM_ACTIVE_CONNECTION_IFACE, - "/org/freedesktop/NetworkManager/ActiveConnection", - { watch: true } - ); - - proxies.addEventListener("added", (_event, proxy) => { - proxy.wait(() => { - this.activeConnectionFromProxy(proxy).then(connection => { - this.connectionIds[proxy.path] = connection.id; - handler({ type: NetworkEventTypes.ACTIVE_CONNECTION_ADDED, payload: connection }); - }); - }); - }); + async subscribeToEvents() { + const activeConnectionProxies = this.proxies.activeConnections; + const connectionProxies = this.proxies.connections; - proxies.addEventListener("changed", (_event, proxy) => { - proxy.wait(() => { - this.activeConnectionFromProxy(proxy).then(connection => { - handler({ type: NetworkEventTypes.ACTIVE_CONNECTION_UPDATED, payload: connection }); - }); - }); - }); + /** @type {(eventType: string) => NetworkEventFn} */ + const handleWrapperActiveConnection = (eventType) => (_event, proxy) => { + const connection = this.activeConnectionFromProxy(proxy); + this.eventsHandler({ type: eventType, payload: connection }); + }; - proxies.addEventListener("removed", (_event, proxy) => { - const connectionId = this.connectionIds[proxy.path]; - delete this.connectionIds[proxy.path]; - handler({ type: NetworkEventTypes.ACTIVE_CONNECTION_REMOVED, payload: connectionId }); - }); + /** @type {(eventType: string) => NetworkEventFn} */ + const handleWrapperConnection = (eventType) => async (_event, proxy) => { + let connection; + + if (eventType === NetworkEventTypes.CONNECTION_REMOVED) { + connection = { id: proxy.id, path: proxy.path }; + } else { + connection = await this.connectionFromProxy(proxy); + } + + this.eventsHandler({ type: eventType, payload: connection }); + }; + + // FIXME: do not build a map (eventTypesMap), just inject the type here + connectionProxies.addEventListener("added", handleWrapperConnection(NetworkEventTypes.CONNECTION_ADDED)); + connectionProxies.addEventListener("changed", handleWrapperConnection(NetworkEventTypes.CONNECTION_UPDATED)); + connectionProxies.addEventListener("removed", handleWrapperConnection(NetworkEventTypes.CONNECTION_REMOVED)); + + // FIXME: do not build a map (eventTypesMap), just inject the type here + activeConnectionProxies.addEventListener("added", handleWrapperActiveConnection(NetworkEventTypes.ACTIVE_CONNECTION_ADDED)); + activeConnectionProxies.addEventListener("changed", handleWrapperActiveConnection(NetworkEventTypes.ACTIVE_CONNECTION_UPDATED)); + activeConnectionProxies.addEventListener("removed", handleWrapperActiveConnection(NetworkEventTypes.ACTIVE_CONNECTION_REMOVED)); } /** @@ -180,10 +412,25 @@ class NetworkManagerAdapter { * https://developer-old.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.html */ async activateConnection(path) { - const proxy = await this.client.proxy(NM_IFACE); + const proxy = await this.client.proxy(IFACE); return proxy.ActivateConnection(path, "/", "/"); } + /** + * Builds a connection object from a Cockpit's proxy object + * + * It retrieves additional information like IPv4 settings. + * + * @private + * @param {object} proxy - Proxy object from /org/freedesktop/NetworkManager/Settings/* + * @return {Promise} + */ + async connectionFromProxy(proxy) { + const settings = await proxy.GetSettings(); + settings.path = proxy.path; + return this.connectionFromSettings(settings); + } + /** * Builds a connection object from a Cockpit's proxy object * @@ -191,13 +438,12 @@ class NetworkManagerAdapter { * * @private * @param {object} proxy - Proxy object from /org/freedesktop/NetworkManager/ActiveConnection/* - * @return {Promise} + * @return {ActiveConnection} */ - async activeConnectionFromProxy(proxy) { + activeConnectionFromProxy(proxy) { + const ip4Config = this.proxies.ip4Configs[proxy.Ip4Config]; let addresses = []; - - if (proxy.State === ConnectionState.ACTIVATED) { - const ip4Config = await this.client.proxy(NM_IP4CONFIG_IFACE, proxy.Ip4Config); + if (ip4Config) { addresses = ip4Config.AddressData.map(this.connectionIPAddress); } @@ -210,18 +456,6 @@ class NetworkManagerAdapter { }; } - /** - * Builds a connection object from a D-Bus path. - * - * @private - * @param {string} path - Connection D-Bus path - * @returns {Promise} - */ - async activeConnectionFromPath(path) { - const proxy = await this.client.proxy(NM_ACTIVE_CONNECTION_IFACE, path); - return this.activeConnectionFromProxy(proxy); - } - /** * * Returns connection settings for the given connection @@ -231,9 +465,9 @@ class NetworkManagerAdapter { * @return {Promise} */ async connectionSettingsObject(id) { - const proxy = await this.client.proxy(NM_SETTINGS_IFACE); + const proxy = await this.client.proxy(SETTINGS_IFACE); const path = await proxy.GetConnectionByUuid(id); - return await this.client.proxy(NM_CONNECTION_IFACE, path); + return await this.client.proxy(CONNECTION_IFACE, path); } /* @@ -255,14 +489,15 @@ class NetworkManagerAdapter { /** * Returns the computer's hostname * - * @returns {Promise} + * @return {string} * * https://developer-old.gnome.org/NetworkManager/stable/gdbus-org.freedesktop.NetworkManager.Settings.html */ - async hostname() { - const proxy = await this.client.proxy(NM_SETTINGS_IFACE); - return proxy.Hostname; + hostname() { + if (!this.proxies.settings) return ""; + + return this.proxies.settings.Hostname; } } -export { NetworkManagerAdapter }; +export { NetworkManagerAdapter, mergeConnectionSettings, securityFromFlags }; diff --git a/web/src/client/network/network_manager.test.js b/web/src/client/network/network_manager.test.js index 4f4611b1ad..91fbf66b47 100644 --- a/web/src/client/network/network_manager.test.js +++ b/web/src/client/network/network_manager.test.js @@ -19,19 +19,36 @@ * find current contact information at www.suse.com. */ -import { NetworkManagerAdapter } from "./network_manager"; +import { securityFromFlags, mergeConnectionSettings, NetworkManagerAdapter } from "./network_manager"; +import { createConnection } from "./model"; import { ConnectionState, ConnectionTypes } from "./index"; import { DBusClient } from "../dbus"; import cockpit from "../../lib/cockpit"; const NM_IFACE = "org.freedesktop.NetworkManager"; const NM_SETTINGS_IFACE = "org.freedesktop.NetworkManager.Settings"; -const NM_ACTIVE_CONNECTION_IFACE = "org.freedesktop.NetworkManager.Connection.Active"; -const NM_IP4CONFIG_IFACE = "org.freedesktop.NetworkManager.IP4Config"; +const IP4CONFIG_IFACE = "org.freedesktop.NetworkManager.IP4Config"; const NM_CONNECTION_IFACE = "org.freedesktop.NetworkManager.Settings.Connection"; +const ACTIVE_CONNECTION_IFACE = "org.freedesktop.NetworkManager.Connection.Active"; +const ACCESS_POINT_IFACE = "org.freedesktop.NetworkManager.AccessPoint"; const dbusClient = new DBusClient(""); +const accessPoints = { + "/org/freedesktop/NetworkManager/AccessPoint/11" : { + Flags: 3, + WpaFlags: 0, + RsnFlags: 392, + Ssid: "VGVzdGluZw==", + Frequency: 2432, + HwAddress: "00:31:92:25:84:FA", + Mode: 2, + MaxBitrate: 270000, + Strength: 76, + LastSeen: 96711 + } +}; + const activeConnections = { "/active/connection/wifi/1": { Id: "active-wifi-connection", @@ -46,9 +63,46 @@ const activeConnections = { State: ConnectionState.ACTIVATED, Type: ConnectionTypes.ETHERNET, Ip4Config: "/ip4Config/1" + }, +}; + +const connections = { + "/org/freedesktop/NetworkManager/Settings/1": { + wait: jest.fn(), + path: "/org/freedesktop/NetworkManager/Settings/1", + GetSettings: () => ({ + connection: { + id: cockpit.variant("s", "Testing"), + uuid: cockpit.variant("s", "1f40ddb0-e6e8-4af8-8b7a-0b3898f0f57a"), + type: cockpit.variant("s", "802-11-wireless") + }, + ipv4: { + addresses: [], + "address-data": cockpit.variant("aa{sv}", []), + method: cockpit.variant("s", "auto"), + dns: cockpit.variant("au", []), + "route-data": [] + }, + "802-11-wireless": { + ssid: cockpit.variant("ay", cockpit.byte_array("Testing")), + hidden: cockpit.variant("b", true), + mode: cockpit.variant("s", "infrastructure") + }, + "802-11-wireless-security": { + "key-mgmt": cockpit.variant("s", "wpa-psk") + } + }) } }; +Object.defineProperties(activeConnections, { + addEventListener: { value: jest.fn(), enumerable: false } +}); + +Object.defineProperties(connections, { + addEventListener: { value: jest.fn(), enumerable: false } +}); + const addressesData = { "/ip4Config/1": { wait: jest.fn(), @@ -70,29 +124,30 @@ const addressesData = { } }; +const ActivateConnectionFn = jest.fn(); const networkProxy = () => ({ wait: jest.fn(), - ActivateConnection: jest.fn(), - ActiveConnections: Object.keys(activeConnections) + ActivateConnection: ActivateConnectionFn, + ActiveConnections: Object.keys(activeConnections), }); +const AddConnectionFn = jest.fn(); const networkSettingsProxy = () => ({ wait: jest.fn(), Hostname: "testing-machine", - GetConnectionByUuid: () => "/org/freedesktop/NetworkManager/Settings/1" + GetConnectionByUuid: () => "/org/freedesktop/NetworkManager/Settings/1", + AddConnection: AddConnectionFn }); -const activeConnectionProxy = path => activeConnections[path]; - -const ipConfigProxy = path => addressesData[path]; - const connectionSettingsMock = { wait: jest.fn(), + path: "/org/freedesktop/NetworkManager/Settings/1", GetSettings: () => ({ connection: { id: cockpit.variant("s", "active-wifi-connection"), "interface-name": cockpit.variant("s", "wlp3s0"), uuid: cockpit.variant("s", "uuid-wifi-1"), + type: cockpit.variant("s", "802-11-wireless") }, ipv4: { addresses: [], @@ -108,26 +163,51 @@ const connectionSettingsMock = { "route-data": [] } }), - Update: jest.fn() + Update: jest.fn(), + Delete: jest.fn() }; const connectionSettingsProxy = () => connectionSettingsMock; describe("NetworkManagerAdapter", () => { beforeEach(() => { - dbusClient.proxy = jest.fn().mockImplementation((iface, path) => { + dbusClient.proxy = jest.fn().mockImplementation(iface => { if (iface === NM_IFACE) return networkProxy(); if (iface === NM_SETTINGS_IFACE) return networkSettingsProxy(); - if (iface === NM_ACTIVE_CONNECTION_IFACE) return activeConnectionProxy(path); - if (iface === NM_IP4CONFIG_IFACE) return ipConfigProxy(path); if (iface === NM_CONNECTION_IFACE) return connectionSettingsProxy(); }); + + dbusClient.proxies = jest.fn().mockImplementation(iface => { + if (iface === ACCESS_POINT_IFACE) return accessPoints; + if (iface === ACTIVE_CONNECTION_IFACE) return activeConnections; + if (iface === NM_CONNECTION_IFACE) return connections; + if (iface === IP4CONFIG_IFACE) return addressesData; + return {}; + }); + }); + + describe("#accessPoints", () => { + it("returns the list of last scanned access points", async () => { + const client = new NetworkManagerAdapter(dbusClient); + await client.setUp(); + const accessPoints = client.accessPoints(); + + expect(accessPoints.length).toEqual(1); + const [testing] = accessPoints; + expect(testing).toEqual({ + ssid: "Testing", + hwAddress: "00:31:92:25:84:FA", + strength: 76, + security: ["WPA2"] + }); + }); }); describe("#activeConnections", () => { it("returns the list of active connections", async () => { const client = new NetworkManagerAdapter(dbusClient); - const availableConnections = await client.activeConnections(); + await client.setUp(); + const availableConnections = client.activeConnections(); expect(availableConnections.length).toEqual(2); const [wireless, ethernet] = availableConnections; @@ -149,6 +229,25 @@ describe("NetworkManagerAdapter", () => { }); }); + describe("#connections", () => { + it("returns the list of settings (profiles)", async () => { + const client = new NetworkManagerAdapter(dbusClient); + await client.setUp(); + const connections = await client.connections(); + + const [wifi] = connections; + + expect(wifi).toEqual({ + name: "Testing", + id: "1f40ddb0-e6e8-4af8-8b7a-0b3898f0f57a", + path: "/org/freedesktop/NetworkManager/Settings/1", + type: ConnectionTypes.WIFI, + ipv4: { method: 'auto', addresses: [], nameServers: [] }, + wireless: { ssid: "Testing", hidden: true }, + }); + }); + }); + describe("#getConnection", () => { it("returns the connection with the given ID", async () => { const client = new NetworkManagerAdapter(dbusClient); @@ -156,6 +255,7 @@ describe("NetworkManagerAdapter", () => { expect(connection).toEqual({ id: "uuid-wifi-1", name: "active-wifi-connection", + type: "802-11-wireless", ipv4: { addresses: [{ address: "192.168.122.200", prefix: 24 }], gateway: "192.168.122.1", @@ -166,6 +266,19 @@ describe("NetworkManagerAdapter", () => { }); }); + describe("#addConnection", () => { + it("adds a connection and activates it", async () => { + const client = new NetworkManagerAdapter(dbusClient); + const connection = createConnection({ name: "Wired connection 1" }); + await client.addConnection(connection); + expect(AddConnectionFn).toHaveBeenCalledWith( + expect.objectContaining({ + connection: expect.objectContaining({ id: cockpit.variant("s", connection.name) }) + }) + ); + }); + }); + describe("#updateConnection", () => { it("updates the connection", async () => { const client = new NetworkManagerAdapter(dbusClient); @@ -176,7 +289,6 @@ describe("NetworkManagerAdapter", () => { gateway: "192.168.1.1", nameServers: ["1.2.3.4"] }; - client.activateConnection = jest.fn(); await client.updateConnection(connection); expect(connectionSettingsMock.Update).toHaveBeenCalledWith(expect.objectContaining( @@ -192,7 +304,106 @@ describe("NetworkManagerAdapter", () => { }) } )); - expect(client.activateConnection).toHaveBeenCalled(); + expect(ActivateConnectionFn).toHaveBeenCalled(); }); }); + + describe("#connectTo", () => { + it("activates the given connection", async () => { + const client = new NetworkManagerAdapter(dbusClient); + await client.setUp(); + const [wifi] = await client.connections(); + await client.connectTo(wifi); + expect(ActivateConnectionFn).toHaveBeenCalledWith(wifi.path, "/", "/"); + }); + }); + + describe("#addAndConnectTo", () => { + it("activates the given connection", async () => { + const client = new NetworkManagerAdapter(dbusClient); + await client.setUp(); + client.addConnection = jest.fn(); + await client.addAndConnectTo("Testing", { security: "wpa-psk", password: "testing.1234" }); + + expect(client.addConnection).toHaveBeenCalledWith( + createConnection({ + name: "Testing", + wireless: { ssid: "Testing", security: "wpa-psk", password: "testing.1234" } + }) + ); + }); + }); + + describe("#deleteConnection", () => { + it("deletes the given connection", async () => { + const client = new NetworkManagerAdapter(dbusClient); + await client.setUp(); + const [wifi] = await client.connections(); + await client.deleteConnection(wifi); + + expect(connectionSettingsMock.Delete).toHaveBeenCalled(); + }); + }); + + describe("#hostname", () => { + it("returns the Network Manager settings hostname", async() => { + const client = new NetworkManagerAdapter(dbusClient); + await client.setUp(); + expect(client.hostname()).toEqual("testing-machine"); + }); + }); +}); + +describe("securityFromFlags", () => { + it("returns an array with the security protocols supported by the given AP flags", () => { + expect(securityFromFlags(1, 0, 0)).toEqual(["WEP"]); + expect(securityFromFlags(1, 0x00000100, 0x00000100)).toEqual(["WPA1", "WPA2"]); + expect(securityFromFlags(1, 0x00000200, 0x00000200)).toEqual(["WPA1", "WPA2", "802.1X"]); + }); +}); + +describe("mergeConnectionSettings", () => { + it("returns an object merging the original settings and the ones from the connection", () => { + const settings = { + uuid: cockpit.variant("s", "ba2b14db-fc6c-40a7-b275-77ef9341880c"), + id: cockpit.variant("s", "Wired connection 1"), + ipv4: { + addresses: cockpit.variant("aau", [[3232266754, 24, 3232266753]]), + "routes-data": cockpit.variant("aau", []) + }, + proxy: {} + + }; + + const connection = createConnection({ + name: "Wired connection 2", + ipv4: { + addresses: [{ address: "192.168.1.2", prefix: 24 }], + gateway: "192.168.1.1" + } + }); + + const newSettings = mergeConnectionSettings(settings, connection); + + expect(newSettings.connection.id).toEqual(cockpit.variant("s", connection.name)); + const expectedIpv4 = ({ + gateway: cockpit.variant("s", "192.168.1.1"), + "address-data": cockpit.variant("aa{sv}", [{ + address: cockpit.variant("s", "192.168.1.2"), + prefix: cockpit.variant("u", 24) + }]), + dns: cockpit.variant("au", []), + method: cockpit.variant("s", "auto"), + "routes-data": cockpit.variant("aau", []) + }); + expect(newSettings.ipv4).toEqual(expect.objectContaining(expectedIpv4)); + expect(newSettings.proxy).not.toBeUndefined(); + }); + + it("does not set a gateway if there are not addresses", () => { + const connection = createConnection({ name: "Wired connection" }); + const settings = {}; + const newSettings = mergeConnectionSettings(settings, connection); + expect(newSettings.gateway).toBeUndefined(); + }); }); diff --git a/web/src/client/network/utils.js b/web/src/client/network/utils.js index 8d53009711..988188aa42 100644 --- a/web/src/client/network/utils.js +++ b/web/src/client/network/utils.js @@ -23,6 +23,10 @@ import ipaddr from "ipaddr.js"; +/** + * @typedef {import("./model").IPAddress} IPAddress + */ + /** * Check if an IP is valid * @@ -87,9 +91,18 @@ const stringToIPInt = (text) => { return num; }; +/** + * Returns given IP address in the X.X.X.X/YY format + * + * @param {IPAddress} addr + * @return {string} + */ +const formatIp = addr => `${addr.address}/${addr.prefix}`; + export { isValidIp, isValidIpPrefix, intToIPString, - stringToIPInt + stringToIPInt, + formatIp }; diff --git a/web/src/client/network/utils.test.js b/web/src/client/network/utils.test.js index 0e9a4a9780..14a64ebd4f 100644 --- a/web/src/client/network/utils.test.js +++ b/web/src/client/network/utils.test.js @@ -21,7 +21,7 @@ // @ts-check -import { isValidIp, isValidIpPrefix, intToIPString, stringToIPInt } from "./utils"; +import { isValidIp, isValidIpPrefix, intToIPString, stringToIPInt, formatIp } from "./utils"; describe("#isValidIp", () => { it("returns true when the IP is valid", () => { @@ -60,3 +60,9 @@ describe("#ip4_from_text", () => { expect(stringToIPInt("1.2.3.4")).toEqual(67305985); }); }); + +describe("formatIp", () => { + it("returns the given IPv4 address in the X.X.X.X/YY format", () => { + expect(formatIp({ address: "1.2.3.4", prefix: 24 })).toEqual("1.2.3.4/24"); + }); +}); diff --git a/web/src/patternfly.scss b/web/src/patternfly.scss index 40f4a7d51e..87578de9a2 100644 --- a/web/src/patternfly.scss +++ b/web/src/patternfly.scss @@ -104,9 +104,7 @@ grid-column: 1/3; } -.pf-c-modal-box__footer { -} - +.pf-c-form__actions, .pf-c-modal-box__footer { // EOS prefers buttons placed at the right // Read https://eosdesignsystem.herokuapp.com/buttons/positioning diff --git a/web/src/utils.js b/web/src/utils.js index ffe052e6fb..d932785c45 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -40,6 +40,22 @@ const partition = (collection, filter) => { return [pass, fail]; }; +/** + * Simple utility function to help building className conditionally + * + * @example + * // returns "bg-yellow w-24" + * classNames("bg-yellow", true && "w-24", false && "h-24"); + * + * @todo Use https://github.com/JedWatson/classnames instead? + * + * @param {...*} CSS classes to join + * @returns {String} CSS classes joined together after ignoring falsy values + */ +function classNames(...classes) { + return classes.filter((item) => !!item).join(' '); +} + /** * @typedef {Object} cancellableWrapper * @property {Promise} promise - Cancellable promise @@ -122,5 +138,6 @@ function useCancellablePromise() { export { partition, + classNames, useCancellablePromise, }; diff --git a/web/src/utils.test.js b/web/src/utils.test.js index 041f2b128b..976896b445 100644 --- a/web/src/utils.test.js +++ b/web/src/utils.test.js @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import { partition } from "./utils"; +import { classNames, partition } from "./utils"; describe("partition", () => { it("returns two groups of elements that do and do not satisfy provided filter", () => { @@ -30,3 +30,15 @@ describe("partition", () => { expect(even).toEqual([2, 4, 6]); }); }); + +describe("classNames", () => { + it("join given arguments, ignoring falsy values", () => { + expect(classNames( + "bg-yellow", + false && "h-24", + undefined, + null, + true && "w-24", + )).toEqual("bg-yellow w-24"); + }); +});