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 }}
+ >
+
+
+
+
+
+
+
+
+ );
+}
+
+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;
};