diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index ad86c440e2..83ddee9ca2 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -382,11 +382,11 @@ "examples": ["jane.doe"] }, "password": { - "title": "User password (plain text or encrypted depending on the \"passwordEncrypted\" field)", + "title": "User password (plain text or encrypted depending on the \"encryptedPassword\" field)", "type": "string", "examples": ["nots3cr3t"] }, - "passwordEncrypted": { + "encryptedPassword": { "title": "Flag for encrypted password (true) or plain text password (false or not defined)", "type": "boolean" } @@ -399,10 +399,10 @@ "additionalProperties": false, "properties": { "password": { - "title": "Root password (plain text or encrypted depending on the \"passwordEncrypted\" field)", + "title": "Root password (plain text or encrypted depending on the \"encryptedPassword\" field)", "type": "string" }, - "passwordEncrypted": { + "encryptedPassword": { "title": "Flag for encrypted password (true) or plain text password (false or not defined)", "type": "boolean" }, diff --git a/rust/agama-lib/src/users/http_client.rs b/rust/agama-lib/src/users/http_client.rs index fa867fb3d1..a879826ddc 100644 --- a/rust/agama-lib/src/users/http_client.rs +++ b/rust/agama-lib/src/users/http_client.rs @@ -66,7 +66,7 @@ impl UsersHTTPClient { let rps = RootPatchSettings { sshkey: None, password: Some(value.to_owned()), - password_encrypted: Some(encrypted), + encrypted_password: Some(encrypted), }; let ret = self.client.patch("/users/root", &rps).await?; Ok(ret) @@ -84,7 +84,7 @@ impl UsersHTTPClient { let rps = RootPatchSettings { sshkey: Some(value.to_owned()), password: None, - password_encrypted: None, + encrypted_password: None, }; let ret = self.client.patch("/users/root", &rps).await?; Ok(ret) diff --git a/rust/agama-lib/src/users/model.rs b/rust/agama-lib/src/users/model.rs index f020ae2df1..66d93c9d6f 100644 --- a/rust/agama-lib/src/users/model.rs +++ b/rust/agama-lib/src/users/model.rs @@ -36,5 +36,5 @@ pub struct RootPatchSettings { /// empty string here means remove password for root pub password: Option, /// specify if patched password is provided in encrypted form - pub password_encrypted: Option, + pub encrypted_password: Option, } diff --git a/rust/agama-lib/src/users/store.rs b/rust/agama-lib/src/users/store.rs index ac1af3b4f2..b8c2214604 100644 --- a/rust/agama-lib/src/users/store.rs +++ b/rust/agama-lib/src/users/store.rs @@ -194,14 +194,14 @@ mod test { when.method(PATCH) .path("/api/users/root") .header("content-type", "application/json") - .body(r#"{"sshkey":null,"password":"1234","passwordEncrypted":false}"#); + .body(r#"{"sshkey":null,"password":"1234","encryptedPassword":false}"#); then.status(200).body("0"); }); let root_mock2 = server.mock(|when, then| { when.method(PATCH) .path("/api/users/root") .header("content-type", "application/json") - .body(r#"{"sshkey":"keykeykey","password":null,"passwordEncrypted":null}"#); + .body(r#"{"sshkey":"keykeykey","password":null,"encryptedPassword":null}"#); then.status(200).body("0"); }); let url = server.url("/api"); diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs index 0b23599ec8..de9c2901d7 100644 --- a/rust/agama-server/src/users/web.rs +++ b/rust/agama-server/src/users/web.rs @@ -243,7 +243,7 @@ async fn patch_root( } else { state .users - .set_root_password(&password, config.password_encrypted == Some(true)) + .set_root_password(&password, config.encrypted_password == Some(true)) .await? } } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index b4b5c8ec77..fac5353fd2 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Sun Dec 1 21:53:21 UTC 2024 - David Diaz + +- Rename flag to set password as encrypted + (gh#agama-project/agama#1787). + ------------------------------------------------------------------- Fri Nov 29 12:14:25 UTC 2024 - Imobach Gonzalez Sosa diff --git a/service/lib/agama/autoyast/root_reader.rb b/service/lib/agama/autoyast/root_reader.rb index 14df63e3c2..84737c8a80 100755 --- a/service/lib/agama/autoyast/root_reader.rb +++ b/service/lib/agama/autoyast/root_reader.rb @@ -41,7 +41,7 @@ def read return {} unless root_user hsh = { "password" => root_user.password.value.to_s } - hsh["passwordEncrypted"] = true if root_user.password.value.encrypted? + hsh["encryptedPassword"] = true if root_user.password.value.encrypted? public_key = root_user.authorized_keys.first hsh["sshPublicKey"] = public_key if public_key diff --git a/service/lib/agama/autoyast/user_reader.rb b/service/lib/agama/autoyast/user_reader.rb index 04c62d8e69..84335845bb 100755 --- a/service/lib/agama/autoyast/user_reader.rb +++ b/service/lib/agama/autoyast/user_reader.rb @@ -46,7 +46,7 @@ def read "password" => user.password.value.to_s } - hsh["passwordEncrypted"] = true if user.password.value.encrypted? + hsh["encryptedPassword"] = true if user.password.value.encrypted? { "user" => hsh } end diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 03e3c00a49..f0b01aa62b 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Sun Dec 1 21:59:11 UTC 2024 - David Diaz + +- Rename flag to set password as encrypted + (gh#agama-project/agama#1787). + ------------------------------------------------------------------- Fri Nov 15 16:48:44 UTC 2024 - Ladislav Slezák diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index d23e817cc2..642c8702d9 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Nov 28 14:34:49 UTC 2024 - David Diaz + +- Request a root authentication method after selecting a product + (gh#agama-project#agama#1787). + ------------------------------------------------------------------- Tue Nov 26 09:30:09 UTC 2024 - Ladislav Slezák diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e0fc16b0b8..1f806f0989 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -27,6 +27,7 @@ import App from "./App"; import { InstallationPhase } from "./types/status"; import { createClient } from "~/client"; import { Product } from "./types/software"; +import { RootUser } from "./types/users"; jest.mock("~/client"); @@ -45,6 +46,7 @@ const microos: Product = { id: "Leap Micro", name: "openSUSE Micro" }; // list of available products let mockProducts: Product[]; let mockSelectedProduct: Product; +let mockRootUser: RootUser; jest.mock("~/queries/software", () => ({ ...jest.requireActual("~/queries/software"), @@ -65,7 +67,7 @@ jest.mock("~/queries/l10n", () => ({ jest.mock("~/queries/issues", () => ({ ...jest.requireActual("~/queries/issues"), useIssuesChanges: () => jest.fn(), - useAllIssues: () => ({ isEmtpy: true }), + useAllIssues: () => ({ isEmpty: true }), })); jest.mock("~/queries/storage", () => ({ @@ -73,6 +75,11 @@ jest.mock("~/queries/storage", () => ({ useDeprecatedChanges: () => jest.fn(), })); +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/storage"), + useRootUser: () => mockRootUser, +})); + const mockClientStatus = { phase: InstallationPhase.Startup, isBusy: true, @@ -104,6 +111,7 @@ describe("App", () => { }); mockProducts = [tumbleweed, microos]; + mockRootUser = { password: true, encryptedPassword: false, sshkey: "FAKE-SSH-KEY" }; }); afterEach(() => { @@ -156,14 +164,47 @@ describe("App", () => { mockClientStatus.isBusy = false; }); - it("renders the application content", async () => { - installerRender(, { withL10n: true }); - await screen.findByText(/Outlet Content/); + describe("when there are no authentication method for root user", () => { + beforeEach(() => { + mockRootUser = { password: false, encryptedPassword: false, sshkey: "" }; + }); + + it("redirects to root user edition", async () => { + installerRender(, { withL10n: true }); + await screen.findByText("Navigating to /users/root/edit"); + }); + }); + + describe("when only root password is set", () => { + beforeEach(() => { + mockRootUser = { password: true, encryptedPassword: false, sshkey: "" }; + }); + it("renders the application content", async () => { + installerRender(, { withL10n: true }); + await screen.findByText(/Outlet Content/); + }); + }); + + describe("when only root SSH public key is set", () => { + beforeEach(() => { + mockRootUser = { password: false, encryptedPassword: false, sshkey: "FAKE-SSH-KEY" }; + }); + it("renders the application content", async () => { + installerRender(, { withL10n: true }); + await screen.findByText(/Outlet Content/); + }); + }); + + describe("when root password and SSH public key are set", () => { + it("renders the application content", async () => { + installerRender(, { withL10n: true }); + await screen.findByText(/Outlet Content/); + }); }); }); }); - describe("on the busy installaiton phase", () => { + describe("on the busy installation phase", () => { beforeEach(() => { mockClientStatus.phase = InstallationPhase.Install; mockClientStatus.isBusy = true; @@ -176,7 +217,7 @@ describe("App", () => { }); }); - describe("on the idle installaiton phase", () => { + describe("on the idle installation phase", () => { beforeEach(() => { mockClientStatus.phase = InstallationPhase.Install; mockClientStatus.isBusy = false; diff --git a/web/src/App.tsx b/web/src/App.tsx index ca9c11b9bc..dd1cf35786 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -32,8 +32,10 @@ import { useL10nConfigChanges } from "~/queries/l10n"; import { useIssuesChanges } from "~/queries/issues"; import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status"; import { useDeprecatedChanges } from "~/queries/storage"; -import { ROOT, PRODUCT } from "~/routes/paths"; +import { useRootUser } from "~/queries/users"; +import { ROOT, PRODUCT, USER } from "~/routes/paths"; import { InstallationPhase } from "~/types/status"; +import { isEmpty } from "~/utils"; /** * Main application component. @@ -44,6 +46,7 @@ function App() { const { connected, error } = useInstallerClientStatus(); const { selectedProduct, products } = useProduct(); const { language } = useInstallerL10n(); + const { password: isRootPasswordDefined, sshkey: rootSSHKey } = useRootUser(); useL10nConfigChanges(); useProductChanges(); useIssuesChanges(); @@ -84,6 +87,16 @@ function App() { return ; } + if ( + phase === InstallationPhase.Config && + !isBusy && + !isRootPasswordDefined && + isEmpty(rootSSHKey) && + location.pathname !== USER.rootUser.edit + ) { + return ; + } + return ; }; diff --git a/web/src/assets/styles/app.scss b/web/src/assets/styles/app.scss index c1ca3baa64..334c79d8ea 100644 --- a/web/src/assets/styles/app.scss +++ b/web/src/assets/styles/app.scss @@ -97,3 +97,9 @@ button.remove-link:hover { --pf-v5-c-notification-drawer__list-item--before--BackgroundColor: none; } } + +form#rootAuthMethods { + .pf-v5-c-file-upload__file-select { + display: none; + } +} diff --git a/web/src/components/core/InstallButton.test.tsx b/web/src/components/core/InstallButton.test.tsx index 452b58f5e9..405be9e851 100644 --- a/web/src/components/core/InstallButton.test.tsx +++ b/web/src/components/core/InstallButton.test.tsx @@ -25,7 +25,7 @@ import { screen, waitFor, within } from "@testing-library/react"; import { installerRender, mockRoutes } from "~/test-utils"; import { InstallButton } from "~/components/core"; import { IssuesList } from "~/types/issues"; -import { PRODUCT, ROOT } from "~/routes/paths"; +import { PRODUCT, ROOT, USER } from "~/routes/paths"; const mockStartInstallationFn = jest.fn(); let mockIssuesList: IssuesList; @@ -116,6 +116,7 @@ describe("InstallButton", () => { ["product selection progress", PRODUCT.progress], ["installation progress", ROOT.installationProgress], ["installation finished", ROOT.installationFinished], + ["root authentication", USER.rootUser.edit], ])(`but the installer is rendering the %s screen`, (_, path) => { beforeEach(() => { mockRoutes(path); diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx index 7978a63c13..5a6d44333d 100644 --- a/web/src/components/core/InstallButton.tsx +++ b/web/src/components/core/InstallButton.tsx @@ -26,7 +26,7 @@ import { Popup } from "~/components/core"; import { startInstallation } from "~/api/manager"; import { useAllIssues } from "~/queries/issues"; import { useLocation } from "react-router-dom"; -import { PRODUCT, ROOT } from "~/routes/paths"; +import { PRODUCT, ROOT, USER } from "~/routes/paths"; import { _ } from "~/i18n"; import { Icon } from "../layout"; @@ -34,8 +34,9 @@ import { Icon } from "../layout"; * List of paths where the InstallButton must not be shown. * * Apart from obvious login and installation paths, it does not make sense to - * show the button neither, when the user is about to change the product nor - * when the installer is setting the chosen product. + * show the button neither, when the user is about to change the product, + * defining the root authentication for the fisrt time, nor when the installer + * is setting the chosen product. * */ const EXCLUDED_FROM = [ ROOT.login, @@ -43,6 +44,7 @@ const EXCLUDED_FROM = [ PRODUCT.progress, ROOT.installationProgress, ROOT.installationFinished, + USER.rootUser.edit, ]; const InstallConfirmationPopup = ({ onAccept, onClose }) => { diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index b518271e2b..799f2413eb 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -60,8 +60,8 @@ type SectionProps = { value?: React.ReactNode; /** Elements to be rendered in the section footer */ actions?: React.ReactNode; - /** As short as possible yet as much as needed text for describing what the section is about, if needed */ - description?: string; + /** A React node with a brief description of what the section is for */ + description?: React.ReactNode; /** The heading level used for the section title */ headerLevel?: TitleProps["headingLevel"]; /** Props to influence PF/Card component wrapping the section */ @@ -106,7 +106,7 @@ const Header = ({ hasGutter = true, children, ...props }) => { * * @example Simple usage * - * * * * @example Complex usage @@ -137,7 +137,7 @@ const Section = ({ const hasTitle = !isEmpty(title); const hasValue = !isEmpty(value); const hasDescription = !isEmpty(description); - const hasHeader = hasTitle || hasValue; + const hasHeader = hasTitle || hasValue || hasDescription; const hasAriaLabel = !isEmpty(ariaLabel) || (isObject(pfCardProps) && "aria-label" in pfCardProps); const props = { ...defaultCardProps, "aria-label": ariaLabel }; @@ -184,7 +184,7 @@ const Section = ({ * * @example * - * Let's go + * Let's go * * */ @@ -285,7 +285,7 @@ const Content = ({ children, ...pageSectionProps }: React.PropsWithChildren )} - - {showProductName && {selectedProduct.name}} - + {title && {title}} diff --git a/web/src/components/users/RootAuthMethodsPage.test.tsx b/web/src/components/users/RootAuthMethodsPage.test.tsx new file mode 100644 index 0000000000..649e4cfd36 --- /dev/null +++ b/web/src/components/users/RootAuthMethodsPage.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU 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 { mockNavigateFn, installerRender } from "~/test-utils"; +import { RootAuthMethodsPage } from "~/components/users"; + +const mockRootUserMutation = { mutateAsync: jest.fn() }; + +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/users"), + useRootUserMutation: () => mockRootUserMutation, +})); + +describe("RootAuthMethodsPage", () => { + it("allows setting a root authentication method", async () => { + const { user } = installerRender(); + const passwordInput = screen.getByLabelText("Password"); + const sshKeyTextarea = screen.getByLabelText("SSH public key"); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // There must be an upload button too (behavior not covered here); + screen.getByRole("button", { name: "upload" }); + + // The Accept button must be enable only when at least one authentication + // method is defined + expect(acceptButton).toHaveAttribute("disabled"); + + await user.type(passwordInput, "s3cr3t"); + expect(acceptButton).not.toHaveAttribute("disabled"); + + await user.clear(passwordInput); + expect(acceptButton).toHaveAttribute("disabled"); + + await user.type(sshKeyTextarea, "FAKE SSH KEY"); + expect(acceptButton).not.toHaveAttribute("disabled"); + + await user.clear(sshKeyTextarea); + expect(acceptButton).toHaveAttribute("disabled"); + + await user.type(passwordInput, "s3cr3t"); + await user.type(sshKeyTextarea, "FAKE SSH KEY"); + expect(acceptButton).not.toHaveAttribute("disabled"); + + // Request setting defined root method when Accept button is clicked + await user.click(acceptButton); + expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ + password: "s3cr3t", + encryptedPassword: false, + sshkey: "FAKE SSH KEY", + }); + + await user.clear(passwordInput); + await user.click(acceptButton); + expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ + sshkey: "FAKE SSH KEY", + }); + + await user.clear(sshKeyTextarea); + await user.type(passwordInput, "t0ps3cr3t"); + await user.click(acceptButton); + expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ + password: "t0ps3cr3t", + encryptedPassword: false, + }); + + // After submitting the data, it must navigate + expect(mockNavigateFn).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/users/RootAuthMethodsPage.tsx b/web/src/components/users/RootAuthMethodsPage.tsx new file mode 100644 index 0000000000..4449502c44 --- /dev/null +++ b/web/src/components/users/RootAuthMethodsPage.tsx @@ -0,0 +1,168 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU 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, { useRef, useState } from "react"; +import { + Button, + FileUpload, + Flex, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, +} from "@patternfly/react-core"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Center } from "~/components/layout"; +import { Page, PasswordInput } from "~/components/core"; +import { useRootUserMutation } from "~/queries/users"; +import { RootUserChanges } from "~/types/users"; +import { ROOT as PATHS } from "~/routes/paths"; +import { isEmpty } from "~/utils"; +import { _ } from "~/i18n"; +import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing"; + +/** + * A page component for setting at least one root authentication method + * + * NOTE: This page will be automatically displayed only when no root authentication + * method is set. It is not within the scope of this component to fill data if + * users manually enter the route path. + */ +function RootAuthMethodsPage() { + const passwordRef = useRef(); + const navigate = useNavigate(); + const location = useLocation(); + const setRootUser = useRootUserMutation(); + const [password, setPassword] = useState(""); + const [sshKey, setSSHKey] = useState(""); + const [isUploading, setIsUploading] = useState(false); + + const startUploading = () => setIsUploading(true); + const stopUploading = () => setIsUploading(false); + const clearKey = () => setSSHKey(""); + + const isFormValid = !isEmpty(password) || !isEmpty(sshKey); + const uploadFile = () => document.getElementById("sshKey-browse-button").click(); + + const accept = async (e: React.SyntheticEvent) => { + e.preventDefault(); + + const data: Partial = {}; + + if (!isEmpty(password)) { + data.password = password; + data.encryptedPassword = false; + } + + if (!isEmpty(sshKey)) { + data.sshkey = sshKey; + } + + if (isEmpty(data)) return; + + await setRootUser.mutateAsync(data); + + navigate(location.state?.from || PATHS.root, { replace: true }); + }; + + // TRANSLATORS: %s will be replaced by a link with the text "upload". + const [sshKeyStartHelperText, sshKeyEndHelperText] = _( + "Write, paste, drop, or %s a SSH public key file in the above textarea.", + ).split("%s"); + // TRANSLATORS: this "upload" is a commanding verb, in the %s place of + // "Write, paste, drop, or %s a SSH public key file in the above textarea." + const uploadLinkText = _("upload"); + + return ( + + +
+ + {_( + "You must define at least one authentication method for the root user. You can still edit them anytime before the installation.", + )} +

+ } + pfCardProps={{ isCompact: false }} + pfCardBodyProps={{ isFilled: true }} + > +
+ + + setPassword(value)} + /> + + + setSSHKey(value)} + onTextChange={(_, value) => setSSHKey(value)} + onReadStarted={startUploading} + onReadFinished={stopUploading} + onClearClick={clearKey} + /> + + + + {sshKeyStartHelperText} + + {sshKeyEndHelperText} + + + + + +
+
+
+
+ + + +
+ ); +} + +export default RootAuthMethodsPage; diff --git a/web/src/components/users/RootPasswordPopup.jsx b/web/src/components/users/RootPasswordPopup.jsx index 2461fd43fc..2ec42eab5d 100644 --- a/web/src/components/users/RootPasswordPopup.jsx +++ b/web/src/components/users/RootPasswordPopup.jsx @@ -57,7 +57,7 @@ export default function RootPasswordPopup({ title = _("Root password"), isOpen, // TODO: handle errors // the web UI only supports plain text passwords, this resets the flag if an encrypted password // was previously set from CLI - if (password !== "") await setRootUser.mutateAsync({ password, passwordEncrypted: false }); + if (password !== "") await setRootUser.mutateAsync({ password, encryptedPassword: false }); close(); }; diff --git a/web/src/components/users/RootPasswordPopup.test.jsx b/web/src/components/users/RootPasswordPopup.test.jsx index 0624a56161..6e6800cd2f 100644 --- a/web/src/components/users/RootPasswordPopup.test.jsx +++ b/web/src/components/users/RootPasswordPopup.test.jsx @@ -77,7 +77,7 @@ describe("when it is open", () => { expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ password, - passwordEncrypted: false, + encryptedPassword: false, }); expect(onCloseCallback).toHaveBeenCalled(); }); diff --git a/web/src/components/users/index.js b/web/src/components/users/index.js index 7f4cadc3b3..064478fa80 100644 --- a/web/src/components/users/index.js +++ b/web/src/components/users/index.js @@ -22,6 +22,7 @@ export { default as FirstUser } from "./FirstUser"; export { default as RootAuthMethods } from "./RootAuthMethods"; +export { default as RootAuthMethodsPage } from "./RootAuthMethodsPage"; export { default as RootPasswordPopup } from "./RootPasswordPopup"; export { default as RootSSHKeyPopup } from "./RootSSHKeyPopup"; export { default as UsersPage } from "./UsersPage"; diff --git a/web/src/languages.json b/web/src/languages.json index 11b8afb8a3..14e4f30ab6 100644 --- a/web/src/languages.json +++ b/web/src/languages.json @@ -12,4 +12,4 @@ "sv-SE": "Svenska", "tr-TR": "Türkçe", "zh-Hans": "中文" -} \ No newline at end of file +} diff --git a/web/src/queries/users.ts b/web/src/queries/users.ts index 5f59051bc2..cc01396995 100644 --- a/web/src/queries/users.ts +++ b/web/src/queries/users.ts @@ -23,7 +23,7 @@ import React from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; -import { RootUser } from "~/types/users"; +import { RootUser, RootUserChanges } from "~/types/users"; import { fetchFirstUser, fetchRoot, @@ -83,12 +83,12 @@ const useFirstUserChanges = () => { return client.onEvent((event) => { if (event.type === "FirstUserChanged") { - const { fullName, userName, password, passwordEncrypted, autologin, data } = event; + const { fullName, userName, password, encryptedPassword, autologin, data } = event; queryClient.setQueryData(["users", "firstUser"], { fullName, userName, password, - passwordEncrypted, + encryptedPassword, autologin, data, }); @@ -117,7 +117,23 @@ const useRootUserMutation = () => { const queryClient = useQueryClient(); const query = { mutationFn: updateRoot, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users", "root"] }), + onMutate: async (newRoot: RootUserChanges) => { + await queryClient.cancelQueries({ queryKey: ["users", "root"] }); + + const previousRoot: RootUser = queryClient.getQueryData(["users", "root"]); + queryClient.setQueryData(["users", "root"], { + password: !!newRoot.password, + sshkey: newRoot.sshkey || previousRoot.sshkey, + }); + return { previousRoot }; + }, + // eslint-disable-next-line n/handle-callback-err + onError: (error, newRoot, context) => { + queryClient.setQueryData(["users", "root"], context.previousRoot); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["users", "root"] }); + }, }; return useMutation(query); }; diff --git a/web/src/router.js b/web/src/router.js index ec9abe6369..f949a76652 100644 --- a/web/src/router.js +++ b/web/src/router.js @@ -33,8 +33,9 @@ import productsRoutes from "~/routes/products"; import storageRoutes from "~/routes/storage"; import softwareRoutes from "~/routes/software"; import usersRoutes from "~/routes/users"; -import { ROOT as PATHS } from "./routes/paths"; +import { ROOT as PATHS, USER } from "./routes/paths"; import { N_ } from "~/i18n"; +import { RootAuthMethodsPage } from "~/components/users"; const rootRoutes = () => [ { @@ -66,7 +67,13 @@ const protectedRoutes = () => [ }, { element: , - children: [productsRoutes()], + children: [ + { + path: USER.rootUser.edit, + element: , + }, + productsRoutes(), + ], }, ], }, diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 63d1047e05..22cdba06e2 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -51,6 +51,9 @@ const ROOT = { const USER = { root: "/users", + rootUser: { + edit: "/users/root/edit", + }, firstUser: { create: "/users/first", edit: "/users/first/edit", diff --git a/web/src/test-utils.js b/web/src/test-utils.js index 40c29fcc16..7580c0be32 100644 --- a/web/src/test-utils.js +++ b/web/src/test-utils.js @@ -76,6 +76,7 @@ jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), useHref: (to) => to, useNavigate: () => mockNavigateFn, + useMatches: () => [], Navigate: ({ to: route }) => <>Navigating to {route}, Outlet: () => <>Outlet Content, useRevalidator: () => mockUseRevalidator, diff --git a/web/src/types/routes.ts b/web/src/types/routes.ts index c333de35d8..11f2135547 100644 --- a/web/src/types/routes.ts +++ b/web/src/types/routes.ts @@ -23,7 +23,11 @@ import { RouteObject } from "react-router-dom"; type RouteHandle = { + /** Text to be used as label when building a link from route information */ name: string; + /** Text to be shown in the layout header as an h1 */ + title?: string; + /** Icon for representing the route in some places, like a menu entry */ icon?: string; }; diff --git a/web/src/types/users.ts b/web/src/types/users.ts index 47e29bdfbe..678fb3ffda 100644 --- a/web/src/types/users.ts +++ b/web/src/types/users.ts @@ -36,7 +36,7 @@ type RootUser = { type RootUserChanges = { password: string; - passwordEncrypted: boolean; + encryptedPassword: boolean; sshkey: string; };