diff --git a/web/src/components/core/Summary.tsx b/web/src/components/core/Summary.tsx
index b093180ccf..ab1f1765a9 100644
--- a/web/src/components/core/Summary.tsx
+++ b/web/src/components/core/Summary.tsx
@@ -23,20 +23,46 @@
import React from "react";
import { Content, Flex, Skeleton, Title } from "@patternfly/react-core";
import Icon, { IconProps } from "~/components/layout/Icon";
-import NestedContent from "./NestedContent";
+import NestedContent from "~/components/core/NestedContent";
+import Text from "~/components/core/Text";
import { _ } from "~/i18n";
import textStyles from "@patternfly/react-styles/css/utilities/Text/text";
+import WarningIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon";
type SummaryProps = {
+ /**
+ * The name of the icon to display next to the title.
+ * Ignored when `hasIssues` is true (warning icon is shown instead).
+ */
icon: IconProps["name"];
- /** The label for the DescriptionListTerm */
+ /**
+ * The label/title for the summary item.
+ * Typically rendered as a heading (h3) for semantic structure.
+ */
title: React.ReactNode;
- /** The primary value of the item */
+ /**
+ * The primary value or content of the summary item.
+ * Displayed below the title with emphasis when issues are present.
+ */
value: React.ReactNode;
- /** Secondary information displayed below the content */
+ /**
+ * Optional secondary information displayed below the primary value.
+ * Rendered in a smaller, subtle text style.
+ */
description?: React.ReactNode;
- /** Whether to display the skeleton loading state */
+ /**
+ * Whether to display skeleton loading placeholders instead of actual content.
+ * When true, shows loading states for both value and description.
+ */
isLoading?: boolean;
+ /**
+ * Whether a summary item has issues that require attention.
+ * When true:
+ * - Displays a warning icon instead of the regular icon
+ * - Applies bold styling to value and description
+ * - Adds warning color styling to the icon
+ */
+ hasIssues?: boolean;
};
const ValueSkeleton = () => (
@@ -48,7 +74,7 @@ const ValueSkeleton = () => (
/>
);
-const DescritionSkeletons = () => (
+const DescriptionSkeletons = () => (
<>
@@ -74,23 +100,45 @@ const DescritionSkeletons = () => (
* />
* ```
*/
-const Summary = ({ title, icon, value, description, isLoading }: SummaryProps) => {
+const Summary = ({
+ title,
+ icon,
+ value,
+ description,
+ isLoading,
+ hasIssues = false,
+}: SummaryProps) => {
return (
-
+ {hasIssues ? (
+
+ ) : (
+
+ )}
{title}
- {isLoading ? : {value}}
{isLoading ? (
-
+
) : (
- <>
- {description && {description}}
- >
+
+ {value}
+
+ )}
+ {isLoading ? (
+
+ ) : (
+ description && (
+
+ {description}
+
+ )
)}
diff --git a/web/src/components/core/Text.test.tsx b/web/src/components/core/Text.test.tsx
index 5a0676f2d2..5be26fbd25 100644
--- a/web/src/components/core/Text.test.tsx
+++ b/web/src/components/core/Text.test.tsx
@@ -33,6 +33,18 @@ describe("Text", () => {
expect(screen.getByText("Installer")).toBeInTheDocument();
});
+ it("renders a 'span' HTML element when component is not given", () => {
+ plainRender(Installer);
+ const element = screen.getByText("Installer");
+ expect(element.tagName).toBe("SPAN");
+ });
+
+ it("renders a 'small' HTML element when component='small'", () => {
+ plainRender(Installer);
+ const element = screen.getByText("Installer");
+ expect(element.tagName).toBe("SMALL");
+ });
+
it("applies bold style when isBold is true", () => {
plainRender(Installer);
expect(screen.getByText("Installer")).toHaveClass(textStyles.fontWeightBold);
diff --git a/web/src/components/core/Text.tsx b/web/src/components/core/Text.tsx
index b97e752a79..91b49cd4d1 100644
--- a/web/src/components/core/Text.tsx
+++ b/web/src/components/core/Text.tsx
@@ -30,6 +30,8 @@ type PageBreakPoints = ReturnType &
React.PropsWithChildren<{
+ /** The HTML element to use for wrapping given children */
+ component?: "small" | "span";
/** Whether apply bold font weight */
isBold?: boolean;
/**
@@ -52,6 +54,7 @@ type TextProps = React.HTMLProps &
* taking precedence.
*/
export default function Text({
+ component = "span",
isBold = false,
srOnly = false,
srOn,
@@ -59,8 +62,10 @@ export default function Text({
children,
...props
}: TextProps) {
+ const Wrapper = component;
+
return (
-
{children}
-
+
);
}
diff --git a/web/src/components/overview/InstallationSettings.tsx b/web/src/components/overview/InstallationSettings.tsx
index 935d5f2fe1..4837bae15d 100644
--- a/web/src/components/overview/InstallationSettings.tsx
+++ b/web/src/components/overview/InstallationSettings.tsx
@@ -28,6 +28,7 @@ import L10nSummary from "~/components/overview/L10nSummary";
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 { _ } from "~/i18n";
import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility";
@@ -49,9 +50,10 @@ export default function InstallationSummarySection() {
+
-
+
>
diff --git a/web/src/components/overview/RegistrationSummary.test.tsx b/web/src/components/overview/RegistrationSummary.test.tsx
new file mode 100644
index 0000000000..b6dc3fa143
--- /dev/null
+++ b/web/src/components/overview/RegistrationSummary.test.tsx
@@ -0,0 +1,112 @@
+/*
+ * 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 { screen } from "@testing-library/react";
+import { installerRender, mockProduct } from "~/test-utils";
+import { useIssues } from "~/hooks/model/issue";
+import { useSystem } from "~/hooks/model/system/software";
+import RegistrationSummary from "./RegistrationSummary";
+
+const mockUseSystem = jest.fn();
+const mockUseIssuesFn: jest.Mock
> = jest.fn();
+
+jest.mock("~/hooks/model/system/software", () => ({
+ ...jest.requireActual("~/hooks/model/system/software"),
+ useSystem: (): jest.Mock> => mockUseSystem(),
+}));
+
+jest.mock("~/hooks/model/issue", () => ({
+ ...jest.requireActual("~/hooks/model/issue"),
+ useIssues: () => mockUseIssuesFn(),
+}));
+
+describe("RegistrationSummary", () => {
+ beforeEach(() => {
+ mockUseIssuesFn.mockReturnValue([]);
+ });
+ describe("when selected product is not registrable", () => {
+ beforeEach(() => {
+ mockProduct({
+ id: "Tumbleweed",
+ name: "openSUSE Tumbleweed",
+ icon: "tumbleweed.svg",
+ description: "Tumbleweed description...",
+ registration: false,
+ });
+ });
+
+ it("renders nothing", () => {
+ const { container } = installerRender();
+ expect(container).toBeEmptyDOMElement();
+ });
+ });
+
+ describe("when selected product is registrable", () => {
+ beforeEach(() => {
+ mockProduct({
+ id: "Tumbleweed",
+ name: "openSUSE Tumbleweed",
+ icon: "tumbleweed.svg",
+ description: "Tumbleweed description...",
+ registration: true,
+ });
+ });
+
+ describe("and it is already registered", () => {
+ beforeEach(() => {
+ mockUseSystem.mockReturnValue({
+ addons: [],
+ patterns: [],
+ repositories: [],
+ registration: { code: "123456789", addons: [] },
+ });
+ });
+
+ it("renders the registration summary with no issues and registered state", () => {
+ installerRender();
+ // Check if the registration summary is displayed with the correct text
+ screen.getByText(/Registration/);
+ screen.getByText(/Registered/);
+ screen.getByText(/Using code ending in/);
+ screen.getByText("6789");
+ });
+ });
+
+ describe("but it is not registered yet", () => {
+ beforeEach(() => {
+ mockUseSystem.mockReturnValue({
+ addons: [],
+ patterns: [],
+ repositories: [],
+ });
+ });
+
+ it("renders the registration summary with no issues and registered state", () => {
+ installerRender();
+ // Check if the registration summary is displayed with the correct text
+ screen.getByText(/Registration/);
+ screen.getByText(/Not registered yet/);
+ });
+ });
+ });
+});
diff --git a/web/src/components/overview/RegistrationSummary.tsx b/web/src/components/overview/RegistrationSummary.tsx
new file mode 100644
index 0000000000..4674fa60ab
--- /dev/null
+++ b/web/src/components/overview/RegistrationSummary.tsx
@@ -0,0 +1,85 @@
+/*
+ * 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 Summary from "~/components/core/Summary";
+import Link from "~/components/core/Link";
+import Text from "~/components/core/Text";
+import { useProductInfo } from "~/hooks/model/config/product";
+import { REGISTRATION } from "~/routes/paths";
+import { _ } from "~/i18n";
+import { useSystem } from "~/hooks/model/system/software";
+import { useIssues } from "~/hooks/model/issue";
+
+/**
+ * Internal component that renders the registration summary content.
+ *
+ * Separated from the parent to avoid unnecessary hook calls when the product
+ * doesn't support registration.
+ *
+ */
+const Content = () => {
+ const { registration } = useSystem();
+ const issues = useIssues("software");
+ const hasIssues = issues.find((i) => i.class === "software.register_system") !== undefined;
+
+ // TRANSLATORS: Brief summary about the product registration.
+ // %s will be replaced with the last 4 digits of the registration code.
+ const [descriptionStart, descriptionEnd] = _("Using code ending in %s").split("%s");
+
+ return (
+
+ {_("Registration")}
+
+ }
+ value={registration ? _("Registered") : _("Not registered yet")}
+ description={
+ registration && (
+ <>
+ {descriptionStart}{" "}
+
+ {registration.code.slice(-4)}
+ {" "}
+ {descriptionEnd}
+ >
+ )
+ }
+ />
+ );
+};
+
+/**
+ * Renders a summary of product registration status.
+ *
+ * Only renders if the product supports registration.
+ */
+export default function RegistrationSummary() {
+ const product = useProductInfo();
+
+ if (!product || !product.registration) return null;
+
+ return ;
+}
diff --git a/web/src/components/overview/StorageSummary.tsx b/web/src/components/overview/StorageSummary.tsx
index 09fb1a2113..02ba325259 100644
--- a/web/src/components/overview/StorageSummary.tsx
+++ b/web/src/components/overview/StorageSummary.tsx
@@ -22,7 +22,7 @@
import React from "react";
import { sprintf } from "sprintf-js";
-import { HelperText, HelperTextItem } from "@patternfly/react-core";
+import { isEmpty } from "radashi";
import Summary from "~/components/core/Summary";
import Link from "~/components/core/Link";
import { useProgressTracking } from "~/hooks/use-progress-tracking";
@@ -91,11 +91,7 @@ const Value = () => {
if (!availableDevices.length) return _("There are no disks available for the installation");
if (configIssues.length) {
- return (
-
- {_("Invalid settings")}
-
- );
+ return _("Invalid settings");
}
if (!model) return _("Using an advanced storage configuration");
@@ -160,9 +156,15 @@ 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");
return (
diff --git a/web/src/components/product/ProductRegistrationPage.tsx b/web/src/components/product/ProductRegistrationPage.tsx
index 6e5333bfc0..4cec023cb1 100644
--- a/web/src/components/product/ProductRegistrationPage.tsx
+++ b/web/src/components/product/ProductRegistrationPage.tsx
@@ -52,7 +52,7 @@ import {
} from "~/components/core";
// import RegistrationExtension from "./RegistrationExtension";
import RegistrationCodeInput from "./RegistrationCodeInput";
-import { HOSTNAME } from "~/routes/paths";
+import { HOSTNAME, ROOT } from "~/routes/paths";
import { isEmpty } from "radashi";
import { mask } from "~/utils";
import { sprintf } from "sprintf-js";
@@ -62,6 +62,7 @@ import { useSystem } from "~/hooks/model/system/software";
import { useProduct, useProductInfo } from "~/hooks/model/config/product";
import { useIssues } from "~/hooks/model/issue";
import { patchConfig } from "~/api";
+import { Navigate } from "react-router";
const FORM_ID = "productRegistration";
const SERVER_LABEL = N_("Registration server");
@@ -432,7 +433,7 @@ export default function ProductRegistrationPage() {
const showIssues = issues.find((i) => i.class === "software.register_system") !== undefined;
// TODO: render something meaningful instead? "Product not registrable"?
- if (!product || !product.registration) return;
+ if (!product || !product.registration) return ;
return (