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