diff --git a/web/src/components/product/LicenseDialog.tsx b/web/src/components/product/LicenseDialog.tsx
new file mode 100644
index 0000000000..c6d2933501
--- /dev/null
+++ b/web/src/components/product/LicenseDialog.tsx
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) [2025] 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, { useState } from "react";
+import { Popup } from "~/components/core";
+import { _ } from "~/i18n";
+import {
+ Divider,
+ MenuToggle,
+ ModalProps,
+ Select,
+ SelectOption,
+ Split,
+ SplitItem,
+ Stack,
+} from "@patternfly/react-core";
+import { Product } from "~/types/software";
+import { sprintf } from "sprintf-js";
+
+function LicenseDialog({ onClose, product }: { onClose: ModalProps["onClose"]; product: Product }) {
+ const [locale, setLocale] = useState("en");
+ const [localeSelectorOpen, setLocaleSelectorOpen] = useState(false);
+ const locales = ["en", "es", "de", "cz", "pt"];
+ const localesToggler = (toggleRef) => (
+ setLocaleSelectorOpen(!localeSelectorOpen)}
+ isExpanded={localeSelectorOpen}
+ >
+ {locale}
+
+ );
+
+ const onLocaleSelection = (_, locale: string) => {
+ setLocale(locale);
+ setLocaleSelectorOpen(false);
+ };
+
+ const eula = "Lorem ipsum";
+
+ return (
+
+
+
+
+
{sprintf(_("License for %s"), product.name)}
+
+
+
+
+ {eula}
+
+
+ {_("Close")}
+
+
+ );
+}
+
+export default LicenseDialog;
diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx
index da2b4d9d67..7211fb3481 100644
--- a/web/src/components/product/ProductSelectionPage.test.tsx
+++ b/web/src/components/product/ProductSelectionPage.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022-2024] SUSE LLC
+ * Copyright (c) [2022-2025] SUSE LLC
*
* All Rights Reserved.
*
@@ -47,6 +47,7 @@ const microOs: Product = {
icon: "microos.svg",
description: "MicroOS description",
registration: "no",
+ licenseId: "fake.license",
};
let mockSelectedProduct: Product;
@@ -67,10 +68,43 @@ jest.mock("~/queries/software", () => ({
describe("ProductSelectionPage", () => {
beforeEach(() => {
- mockSelectedProduct = tumbleweed;
+ mockSelectedProduct = microOs;
registrationInfoMock = { key: "", email: "" };
});
+ describe("when user select a product with license", () => {
+ beforeEach(() => {
+ mockSelectedProduct = undefined;
+ });
+
+ it("force license acceptance for allowing product selection", async () => {
+ const { user } = installerRender();
+ expect(screen.queryByRole("checkbox", { name: /I have read and accept/ })).toBeNull();
+ const selectButton = screen.getByRole("button", { name: "Select" });
+ const microOsOption = screen.getByRole("radio", { name: microOs.name });
+ await user.click(microOsOption);
+ const licenseCheckbox = screen.getByRole("checkbox", { name: /I have read and accept/ });
+ expect(licenseCheckbox).not.toBeChecked();
+ expect(selectButton).toBeDisabled();
+ await user.click(licenseCheckbox);
+ expect(licenseCheckbox).toBeChecked();
+ expect(selectButton).not.toBeDisabled();
+ });
+ });
+
+ describe("when there is a product with license previouly selected", () => {
+ beforeEach(() => {
+ mockSelectedProduct = microOs;
+ });
+
+ it("does not allow revoking license acceptance", async () => {
+ const { user } = installerRender();
+ const licenseCheckbox = screen.getByRole("checkbox", { name: /I have read and accept/ });
+ expect(licenseCheckbox).toBeChecked();
+ expect(licenseCheckbox).toBeDisabled();
+ });
+ });
+
describe("when there is a registration code set", () => {
beforeEach(() => {
registrationInfoMock = { key: "INTERNAL-USE-ONLY-1234-5678", email: "" };
@@ -103,18 +137,18 @@ describe("ProductSelectionPage", () => {
describe("when the user chooses a product and hits the confirmation button", () => {
it("triggers the product selection", async () => {
const { user } = installerRender();
- const productOption = screen.getByRole("radio", { name: microOs.name });
+ const productOption = screen.getByRole("radio", { name: tumbleweed.name });
const selectButton = screen.getByRole("button", { name: "Select" });
await user.click(productOption);
await user.click(selectButton);
- expect(mockConfigMutation).toHaveBeenCalledWith({ product: microOs.id });
+ expect(mockConfigMutation).toHaveBeenCalledWith({ product: tumbleweed.id });
});
});
describe("when the user chooses a product but hits the cancel button", () => {
it("does not trigger the product selection and goes back", async () => {
const { user } = installerRender();
- const productOption = screen.getByRole("radio", { name: microOs.name });
+ const productOption = screen.getByRole("radio", { name: tumbleweed.name });
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await user.click(productOption);
await user.click(cancelButton);
diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx
index 994f4ac269..6ccdaebfd9 100644
--- a/web/src/components/product/ProductSelectionPage.tsx
+++ b/web/src/components/product/ProductSelectionPage.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022-2024] SUSE LLC
+ * Copyright (c) [2022-2025] SUSE LLC
*
* All Rights Reserved.
*
@@ -33,6 +33,9 @@ import {
Stack,
FormGroup,
Button,
+ Checkbox,
+ StackItem,
+ Flex,
} from "@patternfly/react-core";
import { Navigate, useNavigate } from "react-router-dom";
import { Page } from "~/components/core";
@@ -44,6 +47,8 @@ import { sprintf } from "sprintf-js";
import { _ } from "~/i18n";
import { PATHS } from "~/router";
import { isEmpty } from "~/utils";
+import { Product } from "~/types/software";
+import LicenseDialog from "./LicenseDialog";
const ResponsiveGridItem = ({ children }) => (
@@ -102,6 +107,10 @@ function ProductSelectionPage() {
const registration = useRegistration();
const { products, selectedProduct } = useProduct({ suspense: true });
const [nextProduct, setNextProduct] = useState(selectedProduct);
+ // FIXME: should not be accepted by default first selectedProduct is accepted
+ // because it's a singleProduct iso.
+ const [licenseAccepted, setLicenseAccepted] = useState(!!selectedProduct);
+ const [showLicense, setShowLicense] = useState(false);
const [isLoading, setIsLoading] = useState(false);
if (!isEmpty(registration?.key)) return ;
@@ -115,7 +124,22 @@ function ProductSelectionPage() {
}
};
- const isSelectionDisabled = !nextProduct || nextProduct === selectedProduct;
+ const selectProduct = (product: Product) => {
+ setNextProduct(product);
+ setLicenseAccepted(selectedProduct === product);
+ };
+
+ const selectionHasChanged = nextProduct && nextProduct !== selectedProduct;
+ const mountLicenseCheckbox = !isEmpty(nextProduct?.licenseId);
+ const isSelectionDisabled = !selectionHasChanged || (mountLicenseCheckbox && !licenseAccepted);
+
+ const [eulaTextStart, eulaTextLink, eulaTextEnd] = sprintf(
+ // TRANSLATORS: Text used for the license acceptance checkbox. %s will be
+ // replaced with the product name and the text in the square brackets [] is
+ // used for the link to show the license, please keep the brackets.
+ _("I have read and accept the [license] for %s"),
+ nextProduct?.name || selectedProduct?.name,
+ ).split(/[[\]]/);
return (
@@ -131,7 +155,7 @@ function ProductSelectionPage() {
key={index}
product={product}
isChecked={nextProduct === product}
- onChange={() => setNextProduct(product)}
+ onChange={() => selectProduct(product)}
/>
))}
@@ -140,16 +164,60 @@ function ProductSelectionPage() {
+ {showLicense && (
+ setShowLicense(false)}
+ product={nextProduct || selectedProduct}
+ />
+ )}
-
- {selectedProduct && !isLoading && }
-
- {_("Select")}
-
+
+
+
+
+
+
+ {mountLicenseCheckbox && (
+ setLicenseAccepted(accepted)}
+ isDisabled={selectedProduct === nextProduct}
+ id="license-acceptance"
+ form="productSelectionForm"
+ label={
+ <>
+ {eulaTextStart}{" "}
+ {" "}
+ {eulaTextEnd}
+ >
+ }
+ />
+ )}
+
+
+
+
+ {selectedProduct && !isLoading && }
+
+ {_("Select")}
+
+
+
+
+
+
);
diff --git a/web/src/components/product/index.ts b/web/src/components/product/index.ts
index fc63c951d0..2cdef9b4cb 100644
--- a/web/src/components/product/index.ts
+++ b/web/src/components/product/index.ts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2023-2024] SUSE LLC
+ * Copyright (c) [2023-2025] SUSE LLC
*
* All Rights Reserved.
*
@@ -24,3 +24,4 @@ export { default as ProductSelectionPage } from "./ProductSelectionPage";
export { default as ProductSelectionProgress } from "./ProductSelectionProgress";
export { default as ProductRegistrationPage } from "./ProductRegistrationPage";
export { default as ProductRegistrationAlert } from "./ProductRegistrationAlert";
+export { default as EulaDialog } from "./LicenseDialog";
diff --git a/web/src/types/software.ts b/web/src/types/software.ts
index f35a9a609e..adb749021d 100644
--- a/web/src/types/software.ts
+++ b/web/src/types/software.ts
@@ -43,6 +43,8 @@ type Product = {
icon?: string;
/** If product is registrable or not */
registration: "no" | "optional" | "mandatory";
+ /** The product license id, if any */
+ licenseId?: string;
};
type PatternsSelection = { [key: string]: SelectedBy };