diff --git a/web/src/components/overview/InstallationSettings.tsx b/web/src/components/overview/InstallationSettings.tsx
index 4837bae15d..27b2dd7532 100644
--- a/web/src/components/overview/InstallationSettings.tsx
+++ b/web/src/components/overview/InstallationSettings.tsx
@@ -29,6 +29,7 @@ import StorageSummary from "~/components/overview/StorageSummary";
import NetworkSummary from "~/components/overview/NetworkSummary";
import SoftwareSummary from "~/components/overview/SoftwareSummary";
import RegistrationSummary from "~/components/overview/RegistrationSummary";
+import UsersSummary from "~/components/overview/UsersSummary";
import { _ } from "~/i18n";
import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility";
@@ -54,6 +55,7 @@ export default function InstallationSummarySection() {
+
>
diff --git a/web/src/components/overview/RegistrationSummary.tsx b/web/src/components/overview/RegistrationSummary.tsx
index 4674fa60ab..ce8fad449d 100644
--- a/web/src/components/overview/RegistrationSummary.tsx
+++ b/web/src/components/overview/RegistrationSummary.tsx
@@ -40,7 +40,7 @@ import { useIssues } from "~/hooks/model/issue";
const Content = () => {
const { registration } = useSystem();
const issues = useIssues("software");
- const hasIssues = issues.find((i) => i.class === "software.register_system") !== undefined;
+ const hasIssues = issues.find((i) => i.class === "software.missing_registration") !== undefined;
// TRANSLATORS: Brief summary about the product registration.
// %s will be replaced with the last 4 digits of the registration code.
diff --git a/web/src/components/overview/StorageSummary.tsx b/web/src/components/overview/StorageSummary.tsx
index 02ba325259..562fe74097 100644
--- a/web/src/components/overview/StorageSummary.tsx
+++ b/web/src/components/overview/StorageSummary.tsx
@@ -22,7 +22,6 @@
import React from "react";
import { sprintf } from "sprintf-js";
-import { isEmpty } from "radashi";
import Summary from "~/components/core/Summary";
import Link from "~/components/core/Link";
import { useProgressTracking } from "~/hooks/use-progress-tracking";
@@ -156,15 +155,11 @@ const Description = () => {
*/
export default function StorageSummary() {
const { loading } = useProgressTracking("storage");
- // FIXME: Refactor for avoid duplicating these checks about issues and actions
- // TODO: extend tests for covering the hasIssues status
- const actions = useActions();
- const issues = useIssues("storage");
- const configIssues = issues.filter((i) => i.class !== "proposal");
+ const hasIssues = !!useIssues("storage").length;
return (
diff --git a/web/src/components/overview/UsersSummary.tsx b/web/src/components/overview/UsersSummary.tsx
new file mode 100644
index 0000000000..72e01821f4
--- /dev/null
+++ b/web/src/components/overview/UsersSummary.tsx
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) [2026] 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 { sprintf } from "sprintf-js";
+import { useProgressTracking } from "~/hooks/use-progress-tracking";
+import { useConfig } from "~/hooks/model/config";
+import { useIssues } from "~/hooks/model/issue";
+import { USER } from "~/routes/paths";
+import { _ } from "~/i18n";
+import Summary from "~/components/core/Summary";
+import Link from "~/components/core/Link";
+
+const rootConfigured = (config) => {
+ if (!config.root) return false;
+
+ const { password, sshPublicKey } = config.root;
+ if (password && password !== "") return true;
+ if (sshPublicKey && sshPublicKey !== "") return true;
+
+ return false;
+};
+
+const userConfigured = (config) => {
+ if (!config.user) return false;
+
+ const { userName, fullName, password } = config.user;
+ return userName !== "" && fullName !== "" && password !== "";
+};
+
+/**
+ * Renders a summary text describing the authentication configuration.
+ */
+const Value = () => {
+ const config = useConfig();
+ const root = rootConfigured(config);
+ const user = userConfigured(config);
+
+ if (!root && !user) return _("Not configured yet");
+ if (root && !user) return _("Configured for the root user");
+
+ const userName = config.user.userName;
+ // TRANSLATORS: %s is a username like 'jdoe'
+ if (root) return sprintf(_("Configured for root and user '%s'"), userName);
+
+ // TRANSLATORS: %s is a username like 'jdoe'
+ return sprintf(_("Configured for user '%s'"), userName);
+};
+
+/**
+ * Renders the estimated disk space required for the installation.
+ */
+const Description = () => {
+ const config = useConfig();
+ if (!rootConfigured(config)) return;
+
+ const password = config.root.password || "";
+ const sshKey = config.root.sshPublicKey || "";
+
+ if (password !== "" && sshKey !== "") return _("Root login with password and SSH key");
+ if (password !== "") return _("Root login with password");
+ return _("Root login with SSH key");
+};
+
+/**
+ * A software installation summary.
+ */
+export default function UsersSummary() {
+ const { loading } = useProgressTracking("users");
+ const hasIssues = !!useIssues("users").length;
+
+ return (
+
+ {_("Authentication")}
+
+ }
+ value={}
+ description={}
+ isLoading={loading}
+ />
+ );
+}
diff --git a/web/src/components/users/FirstUser.test.tsx b/web/src/components/users/FirstUser.test.tsx
index 2c97368afd..aaa0dc2efa 100644
--- a/web/src/components/users/FirstUser.test.tsx
+++ b/web/src/components/users/FirstUser.test.tsx
@@ -26,19 +26,25 @@ import { installerRender } from "~/test-utils";
import FirstUser from "./FirstUser";
import { USER } from "~/routes/paths";
-const mockFirstUser = jest.fn();
-const mockRemoveFirstUserMutation = jest.fn();
+const mockProposal = jest.fn();
+const mockRemoveUser = jest.fn();
-jest.mock("~/queries/users", () => ({
- ...jest.requireActual("~/queries/users"),
- useFirstUser: () => mockFirstUser(),
- useRemoveFirstUserMutation: () => ({
- mutate: mockRemoveFirstUserMutation,
- }),
+jest.mock("~/hooks/model/proposal", () => ({
+ ...jest.requireActual("~/hooks/model/proposal"),
+ useProposal: () => mockProposal(),
+}));
+
+jest.mock("~/hooks/model/config/user", () => ({
+ ...jest.requireActual("~/hooks/model/config/user"),
+ useRemoveUser: () => mockRemoveUser,
}));
describe("FirstUser", () => {
describe("when the user is not defined yet", () => {
+ beforeEach(() => {
+ mockProposal.mockReturnValue({});
+ });
+
it("renders a link to define it", () => {
installerRender();
const createLink = screen.getByRole("link", { name: "Define a user now" });
@@ -48,7 +54,9 @@ describe("FirstUser", () => {
describe("when the user is already defined", () => {
beforeEach(() => {
- mockFirstUser.mockReturnValue({ fullName: "Gecko Migo", userName: "gmigo" });
+ mockProposal.mockReturnValue({
+ users: { user: { fullName: "Gecko Migo", userName: "gmigo" } },
+ });
});
it("renders the fullname and username", () => {
@@ -69,7 +77,7 @@ describe("FirstUser", () => {
await user.click(moreActionsToggle);
const discardAction = screen.getByRole("menuitem", { name: "Discard" });
await user.click(discardAction);
- expect(mockRemoveFirstUserMutation).toHaveBeenCalled();
+ expect(mockRemoveUser).toHaveBeenCalled();
});
});
});
diff --git a/web/src/components/users/FirstUser.tsx b/web/src/components/users/FirstUser.tsx
index 688431c79f..c853f7a767 100644
--- a/web/src/components/users/FirstUser.tsx
+++ b/web/src/components/users/FirstUser.tsx
@@ -33,14 +33,23 @@ import {
} from "@patternfly/react-core";
import { Link, Page, SplitButton } from "~/components/core";
import PasswordCheck from "~/components/users/PasswordCheck";
-import { useFirstUser, useFirstUserChanges, useRemoveFirstUserMutation } from "~/queries/users";
+// This should be based on the config, not on the proposal. As a temporary hack (introduced in a
+// separate commit that should be easy to revert), we are using the proposal because the config
+// does not emit an event on every change.
+import { useProposal } from "~/hooks/model/proposal";
+import { useRemoveUser } from "~/hooks/model/config/user";
import { PATHS } from "~/routes/users";
import { isEmpty } from "radashi";
import { _ } from "~/i18n";
+const useUser = () => {
+ const proposal = useProposal().users;
+ return proposal?.user;
+};
+
const UserActions = () => {
- const user = useFirstUser();
- const { mutate: removeUser } = useRemoveFirstUserMutation();
+ const user = useUser();
+ const removeUser = useRemoveUser();
if (isEmpty(user?.userName)) {
return (
@@ -60,7 +69,7 @@ const UserActions = () => {
};
const UserData = () => {
- const user = useFirstUser();
+ const user = useUser();
const fullnameTermId = useId();
const usernameTermId = useId();
@@ -94,8 +103,6 @@ const UserData = () => {
};
export default function FirstUser() {
- useFirstUserChanges();
-
return (
() => PasswordCheck Mock
);
-jest.mock("~/queries/users", () => ({
- ...jest.requireActual("~/queries/users"),
- useFirstUser: () => ({
- userName: mockUserName,
- fullName: mockFullName,
- password: mockPassword,
- hashedPassword: mockHashedPassword,
+jest.mock("~/hooks/model/config", () => ({
+ ...jest.requireActual("~/hooks/model/config"),
+ useConfig: () => ({
+ user: {
+ userName: mockUserName,
+ fullName: mockFullName,
+ password: mockPassword,
+ hashedPassword: mockHashedPassword,
+ },
}),
- useFirstUserMutation: () => ({
- mutateAsync: mockFirstUserMutation,
+}));
+
+jest.mock("~/api", () => ({
+ ...jest.requireActual("~/api"),
+ patchConfig: (config) => mockPatchConfig(config),
+}));
+
+// Needed by withL10n
+jest.mock("~/hooks/model/system", () => ({
+ useSystem: () => ({
+ l10n: {
+ keymap: "us",
+ timezone: "Europe/Berlin",
+ locale: "en_US",
+ },
}),
}));
-it.todo("adapt to new api");
-describe.skip("FirstUserForm", () => {
+describe("FirstUserForm", () => {
it("allows using suggested username", async () => {
const { user } = installerRender(, { withL10n: true });
const fullNameInput = screen.getByRole("textbox", { name: "Full name" });
@@ -78,7 +92,7 @@ describe.skip("FirstUserForm", () => {
it("renders the form in 'create' mode", () => {
installerRender(, { withL10n: true });
- screen.getByRole("heading", { name: "Create user" });
+ screen.getByText("Create user");
screen.getByRole("textbox", { name: "Full name" });
screen.getByRole("textbox", { name: "Username" });
// NOTE: Password inputs don't have an implicit role, so they must be
@@ -104,12 +118,14 @@ describe.skip("FirstUserForm", () => {
await user.type(passwordConfirmation, "n0ts3cr3t");
await user.click(acceptButton);
- expect(mockFirstUserMutation).toHaveBeenCalledWith(
+ expect(mockPatchConfig).toHaveBeenCalledWith(
expect.objectContaining({
- fullName: "Gecko Migo",
- userName: "gmigo",
- password: "n0ts3cr3t",
- hashedPassword: false,
+ user: expect.objectContaining({
+ fullName: "Gecko Migo",
+ userName: "gmigo",
+ password: "n0ts3cr3t",
+ hashedPassword: false,
+ }),
}),
);
});
@@ -122,13 +138,13 @@ describe.skip("FirstUserForm", () => {
await user.type(fullname, "Gecko Migo");
await user.click(acceptButton);
- expect(mockFirstUserMutation).not.toHaveBeenCalled();
+ expect(mockPatchConfig).not.toHaveBeenCalled();
screen.getByText("Warning alert:");
screen.getByText("All fields are required");
});
it("renders errors from the server, if any", async () => {
- mockFirstUserMutation.mockRejectedValue({ response: { data: "Username not valid" } });
+ mockPatchConfig.mockRejectedValue({ response: { data: "Username not valid" } });
const { user } = installerRender(, { withL10n: true });
const fullname = screen.getByRole("textbox", { name: "Full name" });
@@ -159,7 +175,7 @@ describe.skip("FirstUserForm", () => {
it("renders the form in 'edit' mode", () => {
installerRender(, { withL10n: true });
- screen.getByRole("heading", { name: "Edit user" });
+ screen.getByText("Edit user");
const fullNameInput = screen.getByRole("textbox", { name: "Full name" });
expect(fullNameInput).toHaveValue("Gecko Migo");
const userNameInput = screen.getByRole("textbox", { name: "Username" });
@@ -184,10 +200,12 @@ describe.skip("FirstUserForm", () => {
await user.type(username, "gloco");
await user.click(acceptButton);
- expect(mockFirstUserMutation).toHaveBeenCalledWith(
+ expect(mockPatchConfig).toHaveBeenCalledWith(
expect.objectContaining({
- fullName: "Gecko Loco",
- userName: "gloco",
+ user: expect.objectContaining({
+ fullName: "Gecko Loco",
+ userName: "gloco",
+ }),
}),
);
});
@@ -210,12 +228,14 @@ describe.skip("FirstUserForm", () => {
await user.type(passwordConfirmation, "m0r3s3cr3t");
await user.click(acceptButton);
- expect(mockFirstUserMutation).toHaveBeenCalledWith(
+ expect(mockPatchConfig).toHaveBeenCalledWith(
expect.objectContaining({
- fullName: "Gecko Loco",
- userName: "gloco",
- password: "m0r3s3cr3t",
- hashedPassword: false,
+ user: expect.objectContaining({
+ fullName: "Gecko Loco",
+ userName: "gloco",
+ password: "m0r3s3cr3t",
+ hashedPassword: false,
+ }),
}),
);
});
@@ -231,8 +251,10 @@ describe.skip("FirstUserForm", () => {
const acceptButton = screen.getByRole("button", { name: "Accept" });
screen.getByText("Using a hashed password.");
await user.click(acceptButton);
- expect(mockFirstUserMutation).toHaveBeenCalledWith(
- expect.not.objectContaining({ hashedPassword: false }),
+ expect(mockPatchConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user: expect.not.objectContaining({ hashedPassword: false }),
+ }),
);
});
@@ -250,8 +272,10 @@ describe.skip("FirstUserForm", () => {
await user.type(passwordInput, "n0tS3cr3t");
await user.type(passwordConfirmationInput, "n0tS3cr3t");
await user.click(acceptButton);
- expect(mockFirstUserMutation).toHaveBeenCalledWith(
- expect.objectContaining({ hashedPassword: false, password: "n0tS3cr3t" }),
+ expect(mockPatchConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user: expect.objectContaining({ hashedPassword: false, password: "n0tS3cr3t" }),
+ }),
);
});
});
diff --git a/web/src/components/users/FirstUserForm.tsx b/web/src/components/users/FirstUserForm.tsx
index 1687122c71..c84561b2b1 100644
--- a/web/src/components/users/FirstUserForm.tsx
+++ b/web/src/components/users/FirstUserForm.tsx
@@ -35,12 +35,12 @@ import {
Button,
} from "@patternfly/react-core";
import { useNavigate } from "react-router";
-import { Loading } from "~/components/layout";
import { PasswordAndConfirmationInput, Page } from "~/components/core";
import PasswordCheck from "~/components/users/PasswordCheck";
import { suggestUsernames } from "~/components/users/utils";
-import { useFirstUser, useFirstUserMutation } from "~/queries/users";
-import { FirstUser } from "~/types/users";
+import { useConfig } from "~/hooks/model/config";
+import { patchConfig } from "~/api";
+import type { User } from "~/model/config";
import { _ } from "~/i18n";
import { USER } from "~/routes/paths";
@@ -83,14 +83,13 @@ const UsernameSuggestions = ({
// close to the related input.
// TODO: extract the suggestions logic.
export default function FirstUserForm() {
- const firstUser = useFirstUser();
- const setFirstUser = useFirstUserMutation();
+ const { user: firstUser } = useConfig();
const [usingHashedPassword, setUsingHashedPassword] = useState(
firstUser ? firstUser.hashedPassword : false,
);
- const [fullName, setFullName] = useState(firstUser?.fullName);
- const [userName, setUserName] = useState(firstUser?.userName);
- const [password, setPassword] = useState(usingHashedPassword ? "" : firstUser?.password);
+ const [fullName, setFullName] = useState(firstUser?.fullName || "");
+ const [userName, setUserName] = useState(firstUser?.userName || "");
+ const [password, setPassword] = useState(usingHashedPassword ? "" : firstUser?.password || "");
const [errors, setErrors] = useState([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [insideDropDown, setInsideDropDown] = useState(false);
@@ -105,9 +104,7 @@ export default function FirstUserForm() {
}
}, [showSuggestions]);
- if (!firstUser) return ;
-
- const isEditing = firstUser.userName !== "";
+ const isEditing = firstUser?.userName !== "";
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -115,10 +112,10 @@ export default function FirstUserForm() {
const nextErrors = [];
const passwordInput = passwordRef.current;
- const data: Partial = {
+ const data: User.Config = {
fullName,
userName,
- password: usingHashedPassword ? firstUser.password : password,
+ password: usingHashedPassword ? firstUser?.password : password,
hashedPassword: usingHashedPassword,
};
@@ -138,8 +135,7 @@ export default function FirstUserForm() {
return;
}
- setFirstUser
- .mutateAsync({ ...data })
+ patchConfig({ user: data })
.then(() => navigate(".."))
.catch((e) => setErrors([e.response.data]));
};
diff --git a/web/src/components/users/RootUser.test.tsx b/web/src/components/users/RootUser.test.tsx
index ef7ccde924..eb76cd0cfd 100644
--- a/web/src/components/users/RootUser.test.tsx
+++ b/web/src/components/users/RootUser.test.tsx
@@ -29,10 +29,9 @@ import { USER } from "~/routes/paths";
let mockPassword: string;
let mockPublicKey: string;
-jest.mock("~/queries/users", () => ({
- ...jest.requireActual("~/queries/users"),
- useRootUser: () => ({ password: mockPassword, sshPublicKey: mockPublicKey }),
- useRootUserChanges: () => jest.fn(),
+jest.mock("~/hooks/model/config", () => ({
+ ...jest.requireActual("~/hooks/model/config"),
+ useConfig: () => ({ root: { password: mockPassword, sshPublicKey: mockPublicKey } }),
}));
const testKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example";
diff --git a/web/src/components/users/RootUser.tsx b/web/src/components/users/RootUser.tsx
index 28fa07d033..8bdf82fe7d 100644
--- a/web/src/components/users/RootUser.tsx
+++ b/web/src/components/users/RootUser.tsx
@@ -33,7 +33,7 @@ import {
} from "@patternfly/react-core";
import { Link, Page } from "~/components/core";
import PasswordCheck from "~/components/users/PasswordCheck";
-import { useRootUser, useRootUserChanges } from "~/queries/users";
+import { useConfig } from "~/hooks/model/config";
import { USER } from "~/routes/paths";
import { isEmpty } from "radashi";
import { _ } from "~/i18n";
@@ -45,8 +45,8 @@ const SSHKeyLabel = ({ sshKey }) => {
};
export default function RootUser() {
- const { password, sshPublicKey } = useRootUser();
- useRootUserChanges();
+ const root = useConfig().root || {};
+ const { password, sshPublicKey } = root;
return (
() => PasswordCheck Mock
);
-jest.mock("~/queries/users", () => ({
- ...jest.requireActual("~/queries/users"),
- useRootUser: () => ({
- password: mockPassword,
- sshPublicKey: mockPublicKey,
- hashedPassword: mockHashedPassword,
+jest.mock("~/hooks/model/config", () => ({
+ ...jest.requireActual("~/hooks/model/config"),
+ useConfig: () => ({
+ root: {
+ password: mockPassword,
+ sshPublicKey: mockPublicKey,
+ hashedPassword: mockHashedPassword,
+ },
}),
- useRootUserMutation: () => ({
- mutateAsync: mockRootUserMutation,
+}));
+
+jest.mock("~/api", () => ({
+ ...jest.requireActual("~/api"),
+ patchConfig: (config) => mockPatchConfig(config),
+}));
+
+// Needed by withL10n
+jest.mock("~/hooks/model/system", () => ({
+ useSystem: () => ({
+ l10n: {
+ keymap: "us",
+ timezone: "Europe/Berlin",
+ locale: "en_US",
+ },
}),
}));
-it.todo("Adapt to new api");
-describe.skip("RootUserForm", () => {
+describe("RootUserForm", () => {
beforeEach(() => {
mockPassword = "n0ts3cr3t";
mockHashedPassword = false;
@@ -62,8 +76,10 @@ describe.skip("RootUserForm", () => {
await user.clear(passwordConfirmationInput);
await user.type(passwordConfirmationInput, "m0r3S3cr3t");
await user.click(acceptButton);
- expect(mockRootUserMutation).toHaveBeenCalledWith(
- expect.objectContaining({ password: "m0r3S3cr3t", hashedPassword: false }),
+ expect(mockPatchConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ root: { password: "m0r3S3cr3t", hashedPassword: false, sshPublicKey: "" },
+ }),
);
});
@@ -79,7 +95,7 @@ describe.skip("RootUserForm", () => {
await user.click(acceptButton);
screen.getByText("Warning alert:");
screen.getByText("Password is empty.");
- expect(mockRootUserMutation).not.toHaveBeenCalled();
+ expect(mockPatchConfig).not.toHaveBeenCalled();
});
it("renders password validation errors, if any", async () => {
@@ -92,7 +108,7 @@ describe.skip("RootUserForm", () => {
await user.click(acceptButton);
screen.getByText("Warning alert:");
screen.getByText("Passwords do not match");
- expect(mockRootUserMutation).not.toHaveBeenCalled();
+ expect(mockPatchConfig).not.toHaveBeenCalled();
});
it("allows clearing the password", async () => {
@@ -103,8 +119,10 @@ describe.skip("RootUserForm", () => {
await user.click(passwordToggle);
expect(passwordToggle).not.toBeChecked();
await user.click(acceptButton);
- expect(mockRootUserMutation).toHaveBeenCalledWith(
- expect.objectContaining({ password: "", hashedPassword: false }),
+ expect(mockPatchConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ root: expect.objectContaining({ password: "", hashedPassword: false }),
+ }),
);
});
@@ -116,9 +134,11 @@ describe.skip("RootUserForm", () => {
const sshPublicKeyInput = screen.getByRole("textbox", { name: "File upload" });
await user.type(sshPublicKeyInput, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example");
await user.click(acceptButton);
- expect(mockRootUserMutation).toHaveBeenCalledWith(
+ expect(mockPatchConfig).toHaveBeenCalledWith(
expect.objectContaining({
- sshPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example",
+ root: expect.objectContaining({
+ sshPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example",
+ }),
}),
);
});
@@ -132,7 +152,7 @@ describe.skip("RootUserForm", () => {
await user.click(acceptButton);
screen.getByText("Warning alert:");
screen.getByText("Public SSH Key is empty.");
- expect(mockRootUserMutation).not.toHaveBeenCalled();
+ expect(mockPatchConfig).not.toHaveBeenCalled();
});
it("allows clearing the public SSH Key", async () => {
@@ -144,8 +164,10 @@ describe.skip("RootUserForm", () => {
await user.click(sshPublicKeyToggle);
expect(sshPublicKeyToggle).not.toBeChecked();
await user.click(acceptButton);
- expect(mockRootUserMutation).toHaveBeenCalledWith(
- expect.objectContaining({ sshPublicKey: "" }),
+ expect(mockPatchConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ root: expect.objectContaining({ sshPublicKey: "" }),
+ }),
);
});
@@ -162,9 +184,14 @@ describe.skip("RootUserForm", () => {
expect(passwordToggle).toBeChecked();
screen.getByText("Using a hashed password.");
await user.click(acceptButton);
- expect(mockRootUserMutation).toHaveBeenCalledWith(
+ expect(mockPatchConfig).toHaveBeenCalledWith(
expect.not.objectContaining({ hashedPassword: false }),
);
+ expect(mockPatchConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ root: expect.not.objectContaining({ hashedPassword: false }),
+ }),
+ );
});
it("allows discarding it", async () => {
@@ -175,8 +202,10 @@ describe.skip("RootUserForm", () => {
await user.click(passwordToggle);
expect(passwordToggle).not.toBeChecked();
await user.click(acceptButton);
- expect(mockRootUserMutation).toHaveBeenCalledWith(
- expect.objectContaining({ hashedPassword: false, password: "" }),
+ expect(mockPatchConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ root: expect.objectContaining({ hashedPassword: false, password: "" }),
+ }),
);
});
@@ -190,8 +219,10 @@ describe.skip("RootUserForm", () => {
await user.type(passwordInput, "n0tS3cr3t");
await user.type(passwordConfirmationInput, "n0tS3cr3t");
await user.click(acceptButton);
- expect(mockRootUserMutation).toHaveBeenCalledWith(
- expect.objectContaining({ hashedPassword: false, password: "n0tS3cr3t" }),
+ expect(mockPatchConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ root: expect.objectContaining({ hashedPassword: false, password: "n0tS3cr3t" }),
+ }),
);
});
});
diff --git a/web/src/components/users/RootUserForm.tsx b/web/src/components/users/RootUserForm.tsx
index f3f4d4ba78..c8b17e526c 100644
--- a/web/src/components/users/RootUserForm.tsx
+++ b/web/src/components/users/RootUserForm.tsx
@@ -33,8 +33,9 @@ import {
} from "@patternfly/react-core";
import { useNavigate } from "react-router";
import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/core";
-import { useRootUser, useRootUserMutation } from "~/queries/users";
-import { RootUser } from "~/types/users";
+import { useConfig } from "~/hooks/model/config";
+import { patchConfig } from "~/api";
+import type { Root } from "~/model/config";
import { isEmpty } from "radashi";
import { _ } from "~/i18n";
import PasswordCheck from "~/components/users/PasswordCheck";
@@ -43,7 +44,7 @@ import { USER } from "~/routes/paths";
const AVAILABLE_METHODS = ["password", "sshPublicKey"] as const;
type ActiveMethods = { [key in (typeof AVAILABLE_METHODS)[number]]?: boolean };
-const initialState = (user: RootUser): ActiveMethods =>
+const initialState = (user: Root.Config): ActiveMethods =>
AVAILABLE_METHODS.reduce((result, key) => {
return { ...result, [key]: !isEmpty(user[key]) };
}, {});
@@ -77,14 +78,13 @@ const SSHKeyField = ({ value, onChange }) => {
const RootUserForm = () => {
const navigate = useNavigate();
- const rootUser = useRootUser();
- const { mutateAsync: updateRootUser } = useRootUserMutation();
+ const rootUser = useConfig().root || {};
const [activeMethods, setActiveMethods] = useState(initialState(rootUser));
const [errors, setErrors] = useState([]);
const [usingHashedPassword, setUsingHashedPassword] = useState(
- rootUser ? rootUser.hashedPassword : false,
+ rootUser ? rootUser?.hashedPassword || false : false,
);
- const [password, setPassword] = useState(usingHashedPassword ? "" : rootUser?.password);
+ const [password, setPassword] = useState(usingHashedPassword ? "" : rootUser?.password || "");
const [sshkey, setSshKey] = useState(rootUser?.sshPublicKey);
const passwordRef = useRef();
@@ -115,7 +115,7 @@ const RootUserForm = () => {
return;
}
- const data: Partial = {
+ const data: Partial = {
sshPublicKey: activeMethods.sshPublicKey ? sshkey : "",
};
@@ -125,11 +125,11 @@ const RootUserForm = () => {
}
if (activeMethods.password) {
- data.password = usingHashedPassword ? rootUser.password : password;
+ data.password = usingHashedPassword ? rootUser?.password || "" : password;
data.hashedPassword = usingHashedPassword;
}
- updateRootUser(data)
+ patchConfig({ root: data })
.then(() => navigate(".."))
.catch((e) => setErrors([e.response.data]));
};
diff --git a/web/src/hooks/model/config/user.ts b/web/src/hooks/model/config/user.ts
new file mode 100644
index 0000000000..8cd1006605
--- /dev/null
+++ b/web/src/hooks/model/config/user.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) [2026] 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 { useSuspenseQuery } from "@tanstack/react-query";
+import { configQuery } from "~/hooks/model/config";
+import { putConfig, Response } from "~/api";
+import type { Config } from "~/model/config";
+
+const removeUserConfig = (data: Config | null): Config =>
+ data ? { ...data, user: undefined } : {};
+
+type RemoveUserFn = () => Response;
+
+function useRemoveUser(): RemoveUserFn {
+ const { data } = useSuspenseQuery({
+ ...configQuery,
+ select: removeUserConfig,
+ });
+ return () => putConfig(data);
+}
+
+export { useRemoveUser };
+export type { RemoveUserFn };
diff --git a/web/src/model/config.ts b/web/src/model/config.ts
index a36d3d9723..bfbd86aac6 100644
--- a/web/src/model/config.ts
+++ b/web/src/model/config.ts
@@ -25,6 +25,8 @@ import type * as L10n from "~/model/config/l10n";
import type * as Network from "~/model/config/network";
import type * as Product from "~/model/config/product";
import type * as Software from "~/model/config/software";
+import type * as User from "~/model/config/user";
+import type * as Root from "~/model/config/root";
import type * as Storage from "~/openapi/config/storage";
type Config = {
@@ -34,6 +36,8 @@ type Config = {
product?: Product.Config;
storage?: Storage.Config;
software?: Software.Config;
+ user?: User.Config;
+ root?: Root.Config;
};
-export type { Config, Hostname, Product, L10n, Network, Storage };
+export type { Config, Hostname, Product, L10n, Network, Storage, User, Root };
diff --git a/web/src/model/config/root.ts b/web/src/model/config/root.ts
new file mode 100644
index 0000000000..74af5cb89a
--- /dev/null
+++ b/web/src/model/config/root.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) [2026] 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.
+ */
+
+type Config = {
+ password?: string;
+ hashedPassword?: boolean;
+ sshPublicKey?: string;
+};
+
+export type { Config };
diff --git a/web/src/model/config/user.ts b/web/src/model/config/user.ts
new file mode 100644
index 0000000000..0c850115f7
--- /dev/null
+++ b/web/src/model/config/user.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) [2026] 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.
+ */
+
+type Config = {
+ fullName: string;
+ userName: string;
+ password: string;
+ hashedPassword?: boolean;
+};
+
+export type { Config };
diff --git a/web/src/model/proposal.ts b/web/src/model/proposal.ts
index efd24bca05..5211f4f29a 100644
--- a/web/src/model/proposal.ts
+++ b/web/src/model/proposal.ts
@@ -25,6 +25,7 @@ import type * as L10n from "~/model/proposal/l10n";
import type * as Network from "~/model/proposal/network";
import type * as Software from "~/model/proposal/software";
import type * as Storage from "~/model/proposal/storage";
+import type * as Users from "~/model/proposal/users";
type Proposal = {
hostname?: Hostname.Proposal;
@@ -32,6 +33,7 @@ type Proposal = {
network: Network.Proposal;
software?: Software.Proposal;
storage?: Storage.Proposal;
+ users?: Users.Proposal;
};
export type { Hostname, Proposal, L10n, Network, Software, Storage };
diff --git a/web/src/model/proposal/users.ts b/web/src/model/proposal/users.ts
new file mode 100644
index 0000000000..55d586823f
--- /dev/null
+++ b/web/src/model/proposal/users.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) [2026] 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 type { User, Root } from "~/model/config";
+
+type Proposal = {
+ root: Root.Config;
+ user: User.Config;
+};
+
+export type { Proposal };