diff --git a/products.d/agama-products.changes b/products.d/agama-products.changes index 06827e89e8..01131826b9 100644 --- a/products.d/agama-products.changes +++ b/products.d/agama-products.changes @@ -1,10 +1,15 @@ +------------------------------------------------------------------- +Wed Jan 8 14:07:21 UTC 2025 - Imobach Gonzalez Sosa + +- Add support for products registration (jsc#PED-11192, + gh#agama-project/agama#1809). + ------------------------------------------------------------------- Tue Jan 7 12:57:13 UTC 2025 - Lubos Kocman - Drop yast from Leap 16.0 software selection code-o-o#leap/features#173 - ------------------------------------------------------------------- Mon Jan 6 14:41:28 UTC 2025 - Angela Briel diff --git a/products.d/sles_160.yaml b/products.d/sles_160.yaml index 136d5daf2f..e73d436ad1 100644 --- a/products.d/sles_160.yaml +++ b/products.d/sles_160.yaml @@ -1,5 +1,7 @@ -id: SLES_16.0 +id: SLES name: SUSE Linux Enterprise Server 16.0 Alpha +registration: "mandatory" +version: "16-0" # ------------------------------------------------------------------------------ # WARNING: When changing the product description delete the translations located # at the at translations/description key below to avoid using obsolete @@ -50,18 +52,7 @@ translations: altyapı için güvenli ve uyarlanabilir işletim sistemidir. Şirket içinde, bulutta ve uçta iş açısından kritik iş yüklerini çalıştırır. software: - installation_repositories: - # Use plain HTTP repositories, HTTPS does not work without importing the SSL - # certificate. It is safe as the repository is GPG checked and you neeed VPN - # to reach the internal server anyway. - - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-Packages-16.0-x86_64/ - archs: x86_64 - - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-Packages-16.0-aarch64/ - archs: aarch64 - - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-Packages-16.0-ppc64le/ - archs: ppc - - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-Packages-16.0-s390x/ - archs: s390 + installation_repositories: [] mandatory_patterns: - base_traditional diff --git a/products.d/sles_sap_160.yaml b/products.d/sles_sap_160.yaml index d48e249357..30c86b7671 100644 --- a/products.d/sles_sap_160.yaml +++ b/products.d/sles_sap_160.yaml @@ -1,5 +1,8 @@ -id: SLES_SAP_16.0 +id: SLES-SAP name: SUSE Linux Enterprise Server for SAP Applications 16.0 Beta +archs: x86_64,aarch64,ppc +registration: "mandatory" +version: "16-0" # ------------------------------------------------------------------------------ # WARNING: When changing the product description delete the translations located # at the at translations/description key below to avoid using obsolete @@ -14,18 +17,7 @@ icon: SUSE.svg translations: description: software: - installation_repositories: - # Use plain HTTP repositories, HTTPS does not work without importing the SSL - # certificate. It is safe as the repository is GPG checked and you neeed VPN - # to reach the internal server anyway. - - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-SAP-Packages-16.0-x86_64/ - archs: x86_64 - - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-SAP-Packages-16.0-aarch64/ - archs: aarch64 - - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-SAP-Packages-16.0-ppc64le/ - archs: ppc - - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-SAP-Packages-16.0-s390x/ - archs: s390 + installation_repositories: [] mandatory_patterns: - base_traditional diff --git a/rust/agama-lib/src/product/client.rs b/rust/agama-lib/src/product/client.rs index 8517ddb9e9..4e6080b4f0 100644 --- a/rust/agama-lib/src/product/client.rs +++ b/rust/agama-lib/src/product/client.rs @@ -19,7 +19,9 @@ // find current contact information at www.suse.com. use std::collections::HashMap; +use std::str::FromStr; +use crate::dbus::get_property; use crate::error::ServiceError; use crate::software::model::RegistrationRequirement; use crate::software::proxies::SoftwareProductProxy; @@ -39,6 +41,8 @@ pub struct Product { pub description: String, /// Product icon (e.g., "default.svg") pub icon: String, + /// Registration requirement + pub registration: RegistrationRequirement, } /// D-Bus client for the software service @@ -72,11 +76,17 @@ impl<'a> ProductClient<'a> { Some(value) => value.try_into().unwrap(), None => "default.svg", }; + + let registration = get_property::(&data, "registration") + .map(|r| RegistrationRequirement::from_str(&r).unwrap_or_default()) + .unwrap_or_default(); + Product { id, name, description: description.to_string(), icon: icon.to_string(), + registration, } }) .collect(); @@ -114,13 +124,6 @@ impl<'a> ProductClient<'a> { Ok(self.registration_proxy.email().await?) } - pub async fn registration_requirement(&self) -> Result { - let requirement = self.registration_proxy.requirement().await?; - // unknown number can happen only if we do programmer mistake - let result: RegistrationRequirement = requirement.try_into().unwrap(); - Ok(result) - } - /// register product pub async fn register(&self, code: &str, email: &str) -> Result<(u32, String), ServiceError> { let mut options: HashMap<&str, &zbus::zvariant::Value> = HashMap::new(); diff --git a/rust/agama-lib/src/product/http_client.rs b/rust/agama-lib/src/product/http_client.rs index 424a8b49f9..29a36f3b3a 100644 --- a/rust/agama-lib/src/product/http_client.rs +++ b/rust/agama-lib/src/product/http_client.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::software::model::RegistrationError; use crate::software::model::RegistrationInfo; use crate::software::model::RegistrationParams; use crate::software::model::SoftwareConfig; @@ -64,13 +65,29 @@ impl ProductHTTPClient { } /// register product - pub async fn register(&self, key: &str, email: &str) -> Result<(u32, String), ServiceError> { + pub async fn register(&self, key: &str, email: &str) -> Result<(), ServiceError> { // note RegistrationParams != RegistrationInfo, fun! let params = RegistrationParams { key: key.to_owned(), email: email.to_owned(), }; + let result = self + .client + .post_void("/software/registration", ¶ms) + .await; - self.client.post("/software/registration", ¶ms).await + let Err(error) = result else { + return Ok(()); + }; + + let message = match error { + ServiceError::BackendError(_, details) => { + let details: RegistrationError = serde_json::from_str(&details).unwrap(); + format!("{} (error code: {})", details.message, details.id) + } + _ => format!("Could not register the product: #{error:?}"), + }; + + Err(ServiceError::FailedRegistration(message)) } } diff --git a/rust/agama-lib/src/product/store.rs b/rust/agama-lib/src/product/store.rs index d43a6166c4..c1a3f42297 100644 --- a/rust/agama-lib/src/product/store.rs +++ b/rust/agama-lib/src/product/store.rs @@ -68,17 +68,8 @@ impl ProductStore { } } if let Some(reg_code) = &settings.registration_code { - let (result, message); - if let Some(email) = &settings.registration_email { - (result, message) = self.product_client.register(reg_code, email).await?; - } else { - (result, message) = self.product_client.register(reg_code, "").await?; - } - // FIXME: name the magic numbers. 3 is Registration not required - // FIXME: well don't register when not required (no regcode in profile) - if result != 0 && result != 3 { - return Err(ServiceError::FailedRegistration(message)); - } + let email = settings.registration_email.as_deref().unwrap_or(""); + self.product_client.register(reg_code, email).await?; probe = true; } diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs index 31f81fbcaf..8556dae11d 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-lib/src/software/model.rs @@ -47,38 +47,36 @@ pub struct RegistrationInfo { pub key: String, /// Registration email. Empty value mean email not used or not registered. pub email: String, - /// if registration is required, optional or not needed for current product. - /// Change only if selected product is changed. - pub requirement: RegistrationRequirement, } -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive( + Clone, + Default, + Debug, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] pub enum RegistrationRequirement { /// Product does not require registration - NotRequired = 0, + #[default] + No = 0, /// Product has optional registration Optional = 1, /// It is mandatory to register the product Mandatory = 2, } -impl TryFrom for RegistrationRequirement { - type Error = (); - - fn try_from(v: u32) -> Result { - match v { - x if x == RegistrationRequirement::NotRequired as u32 => { - Ok(RegistrationRequirement::NotRequired) - } - x if x == RegistrationRequirement::Optional as u32 => { - Ok(RegistrationRequirement::Optional) - } - x if x == RegistrationRequirement::Mandatory as u32 => { - Ok(RegistrationRequirement::Mandatory) - } - _ => Err(()), - } - } +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RegistrationError { + /// ID of error. See dbus API for possible values + pub id: u32, + /// human readable error string intended to be displayed to user + pub message: String, } /// Software resolvable type (package or pattern). diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index f4a0532349..40d49be018 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -37,7 +37,10 @@ use agama_lib::{ error::ServiceError, product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ - model::{RegistrationInfo, RegistrationParams, ResolvableParams, SoftwareConfig}, + model::{ + RegistrationError, RegistrationInfo, RegistrationParams, ResolvableParams, + SoftwareConfig, + }, proxies::{Software1Proxy, SoftwareProductProxy}, Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, }, @@ -49,7 +52,7 @@ use axum::{ routing::{get, post, put}, Json, Router, }; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::collections::HashMap; use tokio_stream::{Stream, StreamExt}; @@ -74,10 +77,6 @@ pub async fn software_streams(dbus: zbus::Connection) -> Result Result, Error> { - // TODO: move registration requirement to product in dbus and so just one event will be needed. - let proxy = RegistrationProxy::new(&dbus).await?; - let stream = proxy - .receive_requirement_changed() - .await - .then(|change| async move { - if let Ok(id) = change.get().await { - // unwrap is safe as possible numbers is send by our controlled dbus - return Some(Event::RegistrationRequirementChanged { - requirement: id.try_into().unwrap(), - }); - } - None - }) - .filter_map(|e| e); - Ok(stream) -} - async fn registration_email_changed_stream( dbus: zbus::Connection, ) -> Result, Error> { @@ -269,19 +247,10 @@ async fn get_registration( let result = RegistrationInfo { key: state.product.registration_code().await?, email: state.product.email().await?, - requirement: state.product.registration_requirement().await?, }; Ok(Json(result)) } -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct FailureDetails { - /// ID of error. See dbus API for possible values - id: u32, - /// human readable error string intended to be displayed to user - message: String, -} - /// Register product /// /// * `state`: service state. @@ -291,7 +260,7 @@ pub struct FailureDetails { context_path = "/api/software", responses( (status = 204, description = "registration successfull"), - (status = 422, description = "Registration failed. Details are in body", body = FailureDetails), + (status = 422, description = "Registration failed. Details are in body", body = RegistrationError), (status = 400, description = "The D-Bus service could not perform the action") ) )] @@ -300,10 +269,10 @@ async fn register( Json(config): Json, ) -> Result { let (id, message) = state.product.register(&config.key, &config.email).await?; - let details = FailureDetails { id, message }; if id == 0 { Ok((StatusCode::NO_CONTENT, ().into_response())) } else { + let details = RegistrationError { id, message }; Ok(( StatusCode::UNPROCESSABLE_ENTITY, Json(details).into_response(), @@ -320,13 +289,13 @@ async fn register( context_path = "/api/software", responses( (status = 200, description = "deregistration successfull"), - (status = 422, description = "De-registration failed. Details are in body", body = FailureDetails), + (status = 422, description = "De-registration failed. Details are in body", body = RegistrationError), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn deregister(State(state): State>) -> Result { let (id, message) = state.product.deregister().await?; - let details = FailureDetails { id, message }; + let details = RegistrationError { id, message }; if id == 0 { Ok((StatusCode::NO_CONTENT, ().into_response())) } else { diff --git a/rust/package/agama.changes b/rust/package/agama.changes index ecf3c6384f..8fa24ecd3f 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jan 8 14:05:34 UTC 2025 - Imobach Gonzalez Sosa + +- Add support for products registration (jsc#PED-11192, + gh#agama-project/agama#1809). + ------------------------------------------------------------------- Fri Dec 20 12:17:26 UTC 2024 - Josef Reidinger diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index 53209ff0a3..40b7d2b1fd 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -58,8 +58,9 @@ def available_products product.id, product.display_name, { - "description" => product.localized_description, - "icon" => product.icon + "description" => product.localized_description, + "icon" => product.icon, + "registration" => product.registration } ] end @@ -177,7 +178,7 @@ def register(reg_code, email: nil) [1, "Product not selected yet"] elsif backend.registration.reg_code [2, "Product already registered"] - elsif backend.registration.requirement == Agama::Registration::Requirement::NOT_REQUIRED + elsif backend.registration.requirement == Agama::Registration::Requirement::NO [3, "Product does not require registration"] else connect_result(first_error_code: 4) do diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 3466147215..7eb4ef60be 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -30,6 +30,16 @@ module Agama # Handles everything related to registration of system to SCC, RMT or similar. class Registration + # NOTE: identical and keep in sync with Software::Manager::TARGET_DIR + TARGET_DIR = "/run/agama/zypp" + private_constant :TARGET_DIR + + # FIXME: it should use TARGET_DIR instead of "/", but connect failed to read it even + # if fs_root passed as client params. Check with SCC guys why. + GLOBAL_CREDENTIALS_PATH = File.join("/", + SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE) + private_constant :GLOBAL_CREDENTIALS_PATH + # Code used for registering the product. # # @return [String, nil] nil if the product is not registered yet. @@ -41,7 +51,7 @@ class Registration attr_reader :email module Requirement - NOT_REQUIRED = :not_required + NO = :no OPTIONAL = :optional MANDATORY = :mandatory end @@ -74,7 +84,7 @@ def register(code, email: "") login, password = SUSE::Connect::YaST.announce_system(connect_params, target_distro) # write the global credentials # TODO: check if we can do it in memory for libzypp - SUSE::Connect::YaST.create_credentials_file(login, password) + SUSE::Connect::YaST.create_credentials_file(login, password, GLOBAL_CREDENTIALS_PATH) target_product = OpenStruct.new( arch: Yast::Arch.rpm_arch, @@ -86,7 +96,8 @@ def register(code, email: "") # if service require specific credentials file, store it @credentials_file = credentials_from_url(@service.url) if @credentials_file - SUSE::Connect::YaST.create_credentials_file(login, password, @credentials_file) + SUSE::Connect::YaST.create_credentials_file(login, password, + File.join(TARGET_DIR, credentials_path(@credentials_file))) end Y2Packager::NewRepositorySetup.instance.add_service(@service.name) @software.add_service(@service) @@ -116,9 +127,9 @@ def deregister email: email } SUSE::Connect::YaST.deactivate_system(connect_params) - FileUtils.rm(SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE) # connect does not remove it itself + FileUtils.rm(GLOBAL_CREDENTIALS_PATH) # connect does not remove it itself if @credentials_file - FileUtils.rm(credentials_path(@credentials_file)) + FileUtils.rm(File.join(TARGET_DIR, credentials_path(@credentials_file))) @credentials_file = nil end @@ -131,10 +142,18 @@ def deregister def finish return unless reg_code - files = [credentials_path(@credentials_file), SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE] - files.each do |file| - dest = File.join(Yast::Installation.destdir, file) - FileUtils.cp(file, dest) + files = [[ + GLOBAL_CREDENTIALS_PATH, File.join(Yast::Installation.destdir, GLOBAL_CREDENTIALS_PATH) + ]] + if @credentials_file + files << [ + File.join(TARGET_DIR, credentials_path(@credentials_file)), + File.join(Yast::Installation.destdir, credentials_path(@credentials_file)) + ] + end + + files.each do |src_dest| + FileUtils.cp(*src_dest) end end @@ -142,10 +161,10 @@ def finish # # @return [Symbol] See {Requirement}. def requirement - return Requirement::NOT_REQUIRED unless product + return Requirement::NO unless product return Requirement::MANDATORY if product.repositories.none? - Requirement::NOT_REQUIRED + Requirement::NO end # Callbacks to be called when registration changes (e.g., a different product is selected). diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 63f4675af5..3312d5138f 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -338,8 +338,6 @@ def registration # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 # rubocop:disable Metrics/AbcSize def add_service(service) - # init repos, so we are sure we operate on "/" and have GPG imported - initialize_target_repos # save repositories before refreshing added services (otherwise # pkg-bindings will treat them as removed by the service refresh and # unload them) diff --git a/service/lib/agama/software/product.rb b/service/lib/agama/software/product.rb index 3fe27489a7..01b219bf09 100644 --- a/service/lib/agama/software/product.rb +++ b/service/lib/agama/software/product.rb @@ -19,6 +19,8 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "agama/registration" + module Agama module Software # Represents a product that Agama can install. @@ -90,6 +92,12 @@ class Product # @return [Array] attr_accessor :user_patterns + # Determines if the product should be registered. + # + # @see Agama::Registration::Requirement + # @return [String] + attr_accessor :registration + # Product translations. # # @example @@ -115,6 +123,7 @@ def initialize(id) @optional_patterns = [] # nil = display all visible patterns, [] = display no patterns @user_patterns = nil + @registration = Agama::Registration::Requirement::NO @translations = {} end diff --git a/service/lib/agama/software/product_builder.rb b/service/lib/agama/software/product_builder.rb index a9ff1d655b..a2507507bd 100644 --- a/service/lib/agama/software/product_builder.rb +++ b/service/lib/agama/software/product_builder.rb @@ -65,6 +65,8 @@ def initialize_product(id, data, attrs) product.name = data[:name] product.version = data[:version] product.icon = attrs["icon"] if attrs["icon"] + product.registration = attrs["registration"] if attrs["registration"] + product.version = attrs["version"] if attrs["version"] end end @@ -98,7 +100,6 @@ def set_translations(product, attrs) def product_data_from_config(id) { name: config.products.dig(id, "software", "base_product"), - version: config.products.dig(id, "software", "version"), icon: config.products.dig(id, "software", "icon"), labels: config.arch_elements_from( id, "software", "installation_labels", property: :label diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 217912e49d..f9f77e40ed 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jan 8 14:05:53 UTC 2025 - Imobach Gonzalez Sosa + +- Add support for products registration (jsc#PED-11192, + gh#agama-project/agama#1809). + ------------------------------------------------------------------- Mon Dec 23 18:40:01 UTC 2024 - Josef Reidinger diff --git a/service/test/agama/dbus/software/product_test.rb b/service/test/agama/dbus/software/product_test.rb index 8ca3d45787..cadcdf8dae 100644 --- a/service/test/agama/dbus/software/product_test.rb +++ b/service/test/agama/dbus/software/product_test.rb @@ -158,7 +158,7 @@ end context "if the registration is not required" do - let(:requirement) { Agama::Registration::Requirement::NOT_REQUIRED } + let(:requirement) { Agama::Registration::Requirement::NO } it "returns 0" do expect(subject.requirement).to eq(0) diff --git a/service/test/agama/registration_test.rb b/service/test/agama/registration_test.rb index a2dd6731b8..1efb802ab5 100644 --- a/service/test/agama/registration_test.rb +++ b/service/test/agama/registration_test.rb @@ -33,6 +33,7 @@ subject { described_class.new(manager, logger) } let(:manager) { instance_double(Agama::Software::Manager) } + let(:product) { Agama::Software::Product.new("test").tap { |p| p.version = "5.0" } } let(:logger) { Logger.new($stdout, level: :warn) } @@ -90,7 +91,9 @@ it "creates credentials file" do expect(SUSE::Connect::YaST).to receive(:create_credentials_file) - .with("test-user", "12345") + .with("test-user", "12345", "/etc/zypp/credentials.d/SCCcredentials") + # TODO: when fixing suse-connect read of fsroot + # .with("test-user", "12345", "/run/agama/zypp/etc/zypp/credentials.d/SCCcredentials") subject.register("11112222", email: "test@test.com") end @@ -117,13 +120,14 @@ before do allow(subject).to receive(:credentials_from_url) - .with("https://credentials/file").and_return("credentials") + .with("https://credentials/file") + .and_return("productA") end it "creates the credentials file" do expect(SUSE::Connect::YaST).to receive(:create_credentials_file) expect(SUSE::Connect::YaST).to receive(:create_credentials_file) - .with("test-user", "12345", "credentials") + .with("test-user", "12345", "/run/agama/zypp/etc/zypp/credentials.d/productA") subject.register("11112222", email: "test@test.com") end @@ -346,7 +350,7 @@ let(:product) { nil } it "returns not required" do - expect(subject.requirement).to eq(Agama::Registration::Requirement::NOT_REQUIRED) + expect(subject.requirement).to eq(Agama::Registration::Requirement::NO) end end @@ -359,7 +363,7 @@ let(:repositories) { ["https://repo"] } it "returns not required" do - expect(subject.requirement).to eq(Agama::Registration::Requirement::NOT_REQUIRED) + expect(subject.requirement).to eq(Agama::Registration::Requirement::NO) end end @@ -372,4 +376,41 @@ end end end + + describe "#finish" do + context "system is not registered" do + before do + subject.instance_variable_set(:@reg_code, nil) + end + + it "do nothing" do + expect(::FileUtils).to_not receive(:cp) + + subject.finish + end + end + + context "system is registered" do + before do + subject.instance_variable_set(:@reg_code, "test") + subject.instance_variable_set(:@credentials_file, "test") + Yast::Installation.destdir = "/mnt" + allow(::FileUtils).to receive(:cp) + end + + it "copies global credentials file" do + expect(::FileUtils).to receive(:cp).with("/etc/zypp/credentials.d/SCCcredentials", + "/mnt/etc/zypp/credentials.d/SCCcredentials") + + subject.finish + end + + it "copies product credentials file" do + expect(::FileUtils).to receive(:cp).with("/run/agama/zypp/etc/zypp/credentials.d/test", + "/mnt/etc/zypp/credentials.d/test") + + subject.finish + end + end + end end diff --git a/service/test/agama/software/product_builder_test.rb b/service/test/agama/software/product_builder_test.rb index a48bc87ceb..cf67c02b0f 100644 --- a/service/test/agama/software/product_builder_test.rb +++ b/service/test/agama/software/product_builder_test.rb @@ -41,6 +41,8 @@ "id" => "Test1", "name" => "Product Test 1", "description" => "This is a test product named Test 1", + "version" => "1.0", + "registration" => "mandatory", "translations" => { "description" => { "cs" => "Czech", @@ -84,19 +86,19 @@ "archs" => "aarch64" } ], - "base_product" => "Test1", - "version" => "1.0" + "base_product" => "Test1" } }, { - "id" => "Test2", - "name" => "Product Test 2", - "description" => "This is a test product named Test 2", - "archs" => "x86_64,aarch64", - "software" => { + "id" => "Test2", + "name" => "Product Test 2", + "description" => "This is a test product named Test 2", + "archs" => "x86_64,aarch64", + "version" => "2.0", + "registration" => "optional", + "software" => { "mandatory_patterns" => ["pattern2-1"], - "base_product" => "Test2", - "version" => "2.0" + "base_product" => "Test2" } }, { @@ -143,6 +145,7 @@ description: "This is a test product named Test 1", name: "Test1", version: "1.0", + registration: "mandatory", repositories: ["https://repos/test1/x86_64/product/"], mandatory_patterns: ["pattern1-1", "pattern1-2"], optional_patterns: ["pattern1-3"], @@ -200,6 +203,7 @@ description: "This is a test product named Test 2", name: "Test2", version: "2.0", + registration: "optional", repositories: [], mandatory_patterns: ["pattern2-1"], optional_patterns: [], diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 734d86f2e8..f330f2b9e1 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jan 8 16:07:13 UTC 2025 - Imobach Gonzalez Sosa + +- Add support for products registration (jsc#PED-11192, + gh#agama-project/agama#1809). + ------------------------------------------------------------------- Wed Jan 8 15:16:51 UTC 2025 - David Diaz diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index a7a58a5e8d..b106985e13 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -40,8 +40,8 @@ jest.mock("~/api/l10n", () => ({ updateConfig: jest.fn(), })); -const tumbleweed: Product = { id: "openSUSE", name: "openSUSE Tumbleweed" }; -const microos: Product = { id: "Leap Micro", name: "openSUSE Micro" }; +const tumbleweed: Product = { id: "openSUSE", name: "openSUSE Tumbleweed", registration: "no" }; +const microos: Product = { id: "Leap Micro", name: "openSUSE Micro", registration: "no" }; // list of available products let mockProducts: Product[]; diff --git a/web/src/api/software.ts b/web/src/api/software.ts index 3be92e58ff..82f8650dc5 100644 --- a/web/src/api/software.ts +++ b/web/src/api/software.ts @@ -20,8 +20,14 @@ * find current contact information at www.suse.com. */ -import { Pattern, Product, SoftwareConfig, SoftwareProposal } from "~/types/software"; -import { get, put } from "~/api/http"; +import { + Pattern, + Product, + SoftwareConfig, + RegistrationInfo, + SoftwareProposal, +} from "~/types/software"; +import { del, get, post, put } from "~/api/http"; /** * Returns the software configuration @@ -38,6 +44,11 @@ const fetchProposal = (): Promise => get("/api/software/propos */ const fetchProducts = (): Promise => get("/api/software/products"); +/** + * Returns an object with the registration info + */ +const fetchRegistration = (): Promise => get("/api/software/registration"); + /** * Returns the list of patterns for the selected product */ @@ -50,4 +61,24 @@ const fetchPatterns = (): Promise => get("/api/software/patterns"); */ const updateConfig = (config: SoftwareConfig) => put("/api/software/config", config); -export { fetchConfig, fetchPatterns, fetchProposal, fetchProducts, updateConfig }; +/** + * Request registration of selected product with given key + */ +const register = ({ key, email }: { key: string; email?: string }) => + post("/api/software/registration", { key, email }); + +/** + * Request deregistering selected product + */ +const deregister = () => del("/api/software/registration"); + +export { + fetchConfig, + fetchPatterns, + fetchProposal, + fetchProducts, + fetchRegistration, + updateConfig, + register, + deregister, +}; diff --git a/web/src/components/core/ChangeProductLink.test.tsx b/web/src/components/core/ChangeProductLink.test.tsx index ddbdd369d1..e6fe63daee 100644 --- a/web/src/components/core/ChangeProductLink.test.tsx +++ b/web/src/components/core/ChangeProductLink.test.tsx @@ -32,12 +32,14 @@ const tumbleweed: Product = { name: "openSUSE Tumbleweed", icon: "tumbleweed.svg", description: "Tumbleweed description...", + registration: "no", }; const microos: Product = { id: "MicroOS", name: "openSUSE MicroOS", icon: "MicroOS.svg", description: "MicroOS description", + registration: "no", }; let mockUseProduct: { products: Product[]; selectedProduct?: Product }; diff --git a/web/src/components/core/FormValidationError.test.jsx b/web/src/components/core/FormValidationError.test.jsx index 3ee1651071..e1b6b7e3bd 100644 --- a/web/src/components/core/FormValidationError.test.jsx +++ b/web/src/components/core/FormValidationError.test.jsx @@ -22,26 +22,30 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import { FormValidationError } from "~/components/core"; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + it("renders nothing when message is null", () => { - const { container } = plainRender(); + const { container } = installerRender(); expect(container).toBeEmptyDOMElement(); }); it("renders nothing when message is empty", () => { - const { container } = plainRender(); + const { container } = installerRender(); expect(container).toBeEmptyDOMElement(); }); it("renders nothing when message is not defined", () => { - const { container } = plainRender(); + const { container } = installerRender(); expect(container).toBeEmptyDOMElement(); }); it("renders a PatternFly error with given message", () => { - plainRender(); + installerRender(); const node = screen.getByText("Invalid input"); expect(node.parentNode.classList.contains("pf-m-error")).toBe(true); }); diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx index 5a6d44333d..93826fe188 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, USER } from "~/routes/paths"; +import { SUPPORTIVE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; import { Icon } from "../layout"; @@ -38,14 +38,6 @@ import { Icon } from "../layout"; * defining the root authentication for the fisrt time, nor when the installer * is setting the chosen product. * */ -const EXCLUDED_FROM = [ - ROOT.login, - PRODUCT.changeProduct, - PRODUCT.progress, - ROOT.installationProgress, - ROOT.installationFinished, - USER.rootUser.edit, -]; const InstallConfirmationPopup = ({ onAccept, onClose }) => { return ( @@ -90,7 +82,7 @@ const InstallButton = ( const location = useLocation(); const hasIssues = !issues.isEmpty; - if (EXCLUDED_FROM.includes(location.pathname)) return; + if (SUPPORTIVE_PATHS.includes(location.pathname)) return; const { onClickWithIssues, ...buttonProps } = props; const open = async () => setIsOpen(true); diff --git a/web/src/components/core/IssuesDrawer.tsx b/web/src/components/core/IssuesDrawer.tsx index 38b205d2c7..13d3ae31c2 100644 --- a/web/src/components/core/IssuesDrawer.tsx +++ b/web/src/components/core/IssuesDrawer.tsx @@ -49,6 +49,7 @@ const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => { users: _("Users"), storage: _("Storage"), software: _("Software"), + product: _("Registration"), }; if (issues.isEmpty || phase === InstallationPhase.Install) return; @@ -65,6 +66,8 @@ const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => {

{Object.entries(issuesByScope).map(([scope, issues], idx) => { if (issues.length === 0) return null; + // FIXME: address this better or use the /product(s)? namespace instead of + // /registration. const ariaLabelId = `${scope}-issues-section`; return ( diff --git a/web/src/components/core/LoginPage.test.tsx b/web/src/components/core/LoginPage.test.tsx index 91ea58652a..8256357ee8 100644 --- a/web/src/components/core/LoginPage.test.tsx +++ b/web/src/components/core/LoginPage.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { installerRender, mockRoutes, plainRender } from "~/test-utils"; +import { installerRender, mockRoutes } from "~/test-utils"; import { LoginPage } from "~/components/core"; import { AuthErrors } from "~/context/auth"; import { PlainLayout } from "../layout"; @@ -36,6 +36,10 @@ const mockLoginFn = jest.fn(); const phase: InstallationPhase = InstallationPhase.Startup; const isBusy: boolean = false; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + jest.mock("~/queries/status", () => ({ useInstallerStatus: () => ({ phase, @@ -86,12 +90,12 @@ describe("LoginPage", () => { describe("when user is not authenticated", () => { it("renders reference to root", () => { - plainRender(); + installerRender(); screen.getAllByText(/root/); }); it("allows entering a password", async () => { - const { user } = plainRender(); + const { user } = installerRender(); const form = screen.getByRole("form", { name: "Login form" }); const passwordInput = within(form).getByLabelText("Password input"); const loginButton = within(form).getByRole("button", { name: "Log in" }); @@ -109,7 +113,7 @@ describe("LoginPage", () => { }); it("renders an authentication error", async () => { - const { user } = plainRender(); + const { user } = installerRender(); const form = screen.getByRole("form", { name: "Login form" }); const passwordInput = within(form).getByLabelText("Password input"); const loginButton = within(form).getByRole("button", { name: "Log in" }); @@ -130,7 +134,7 @@ describe("LoginPage", () => { }); it("renders a server error text", async () => { - const { user } = plainRender(); + const { user } = installerRender(); const form = screen.getByRole("form", { name: "Login form" }); const passwordInput = within(form).getByLabelText("Password input"); const loginButton = within(form).getByRole("button", { name: "Log in" }); diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx index 20f3393fde..5c90132aa1 100644 --- a/web/src/components/core/Page.test.tsx +++ b/web/src/components/core/Page.test.tsx @@ -22,11 +22,17 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { plainRender, mockNavigateFn } from "~/test-utils"; +import { plainRender, mockNavigateFn, mockRoutes, installerRender } from "~/test-utils"; import { Page } from "~/components/core"; +import { _ } from "~/i18n"; +import { PRODUCT, ROOT, USER } from "~/routes/paths"; let consoleErrorSpy: jest.SpyInstance; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlertMock
+)); + describe("Page", () => { beforeAll(() => { consoleErrorSpy = jest.spyOn(console, "error"); @@ -92,10 +98,33 @@ describe("Page", () => { describe("Page.Content", () => { it("renders a node that fills all the available space", () => { - plainRender(The Content); + installerRender({_("The Content")}); const content = screen.getByText("The Content"); expect(content.classList.contains("pf-m-fill")).toBe(true); }); + + it("mounts a ProductRegistrationAlert", () => { + installerRender(); + screen.getByText("ProductRegistrationAlertMock"); + }); + + describe.each([ + ["login", ROOT.login], + ["product selection", PRODUCT.changeProduct], + ["product selection progress", PRODUCT.progress], + ["installation progress", ROOT.installationProgress], + ["installation finished", ROOT.installationFinished], + ["root authentication", USER.rootUser.edit], + ])(`but at %s path`, (_, path) => { + beforeEach(() => { + mockRoutes(path); + }); + + it("does not mount ProductRegistrationAlert", () => { + installerRender(); + expect(screen.queryByText("ProductRegistrationAlertMock")).toBeNull(); + }); + }); }); describe("Page.Cancel", () => { diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 799f2413eb..f72d6ba171 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -40,11 +40,13 @@ import { TitleProps, } from "@patternfly/react-core"; import { Flex } from "~/components/layout"; +import { ProductRegistrationAlert } from "~/components/product"; import { _ } from "~/i18n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; -import { To, useNavigate } from "react-router-dom"; +import { To, useLocation, useNavigate } from "react-router-dom"; import { isEmpty, isObject } from "~/utils"; +import { SUPPORTIVE_PATHS } from "~/routes/paths"; /** * Props accepted by Page.Section @@ -278,11 +280,19 @@ const Submit = ({ children, ...props }: SubmitActionProps) => { * * @see [Patternfly Page/PageSection](https://www.patternfly.org/components/page#pagesection) */ -const Content = ({ children, ...pageSectionProps }: React.PropsWithChildren) => ( - - {children} - -); +const Content = ({ children, ...pageSectionProps }: React.PropsWithChildren) => { + const location = useLocation(); + const mountRegistrationAlert = !SUPPORTIVE_PATHS.includes(location.pathname); + + return ( + <> + {mountRegistrationAlert && } + + {children} + + + ); +}; /** * Component for structuring an Agama page, built on top of PF/Page/PageGroup. diff --git a/web/src/components/core/ServerError.test.tsx b/web/src/components/core/ServerError.test.tsx index 89cbf042d5..4b52ee8b48 100644 --- a/web/src/components/core/ServerError.test.tsx +++ b/web/src/components/core/ServerError.test.tsx @@ -27,6 +27,10 @@ import { installerRender } from "~/test-utils"; import * as utils from "~/utils"; import { ServerError } from "~/components/core"; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + jest.mock("~/components/layout/Header", () => () =>
Header Mock
); jest.mock("~/components/layout/Sidebar", () => () =>
Sidebar Mock
); jest.mock("~/components/layout/Layout", () => { diff --git a/web/src/components/l10n/KeyboardSelection.test.tsx b/web/src/components/l10n/KeyboardSelection.test.tsx index 10c85d6f8a..c22c3c5894 100644 --- a/web/src/components/l10n/KeyboardSelection.test.tsx +++ b/web/src/components/l10n/KeyboardSelection.test.tsx @@ -24,7 +24,7 @@ import React from "react"; import KeyboardSelection from "./KeyboardSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; -import { mockNavigateFn, plainRender } from "~/test-utils"; +import { mockNavigateFn, installerRender } from "~/test-utils"; const keymaps = [ { id: "us", name: "English" }, @@ -35,6 +35,10 @@ const mockConfigMutation = { mutate: jest.fn(), }; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + jest.mock("~/queries/l10n", () => ({ ...jest.requireActual("~/queries/l10n"), useConfigMutation: () => mockConfigMutation, @@ -47,7 +51,7 @@ jest.mock("react-router-dom", () => ({ })); it("allows changing the keyboard", async () => { - plainRender(); + installerRender(); const option = await screen.findByText("Spanish"); await userEvent.click(option); diff --git a/web/src/components/l10n/L10nPage.test.tsx b/web/src/components/l10n/L10nPage.test.tsx index 48f26d6255..d9bbb380be 100644 --- a/web/src/components/l10n/L10nPage.test.tsx +++ b/web/src/components/l10n/L10nPage.test.tsx @@ -22,9 +22,13 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import L10nPage from "~/components/l10n/L10nPage"; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + let mockLoadedData; const locales = [ @@ -58,7 +62,7 @@ beforeEach(() => { }); it("renders a section for configuring the language", () => { - plainRender(); + installerRender(); const region = screen.getByRole("region", { name: "Language" }); within(region).getByText("English - United States"); within(region).getByText("Change"); @@ -70,7 +74,7 @@ describe("if there is no selected language", () => { }); it("renders a button for selecting a language", () => { - plainRender(); + installerRender(); const region = screen.getByRole("region", { name: "Language" }); within(region).getByText("Not selected yet"); within(region).getByText("Select"); @@ -78,7 +82,7 @@ describe("if there is no selected language", () => { }); it("renders a section for configuring the keyboard", () => { - plainRender(); + installerRender(); const region = screen.getByRole("region", { name: "Keyboard" }); within(region).getByText("English"); within(region).getByText("Change"); @@ -90,7 +94,7 @@ describe("if there is no selected keyboard", () => { }); it("renders a button for selecting a keyboard", () => { - plainRender(); + installerRender(); const region = screen.getByRole("region", { name: "Keyboard" }); within(region).getByText("Not selected yet"); within(region).getByText("Select"); @@ -98,7 +102,7 @@ describe("if there is no selected keyboard", () => { }); it("renders a section for configuring the time zone", () => { - plainRender(); + installerRender(); const region = screen.getByRole("region", { name: "Time zone" }); within(region).getByText("Europe - Berlin"); within(region).getByText("Change"); @@ -110,7 +114,7 @@ describe("if there is no selected time zone", () => { }); it("renders a button for selecting a time zone", () => { - plainRender(); + installerRender(); const region = screen.getByRole("region", { name: "Time zone" }); within(region).getByText("Not selected yet"); within(region).getByText("Select"); diff --git a/web/src/components/l10n/LocaleSelection.test.tsx b/web/src/components/l10n/LocaleSelection.test.tsx index d00cb87769..b2d4a98aa5 100644 --- a/web/src/components/l10n/LocaleSelection.test.tsx +++ b/web/src/components/l10n/LocaleSelection.test.tsx @@ -24,7 +24,7 @@ import React from "react"; import LocaleSelection from "./LocaleSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; -import { mockNavigateFn, plainRender } from "~/test-utils"; +import { mockNavigateFn, installerRender } from "~/test-utils"; const locales = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, @@ -35,6 +35,10 @@ const mockConfigMutation = { mutate: jest.fn(), }; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + jest.mock("~/queries/l10n", () => ({ ...jest.requireActual("~/queries/l10n"), useL10n: () => ({ locales, selectedLocale: locales[0] }), @@ -47,7 +51,7 @@ jest.mock("react-router-dom", () => ({ })); it("allows changing the keyboard", async () => { - plainRender(); + installerRender(); const option = await screen.findByText("Spanish"); await userEvent.click(option); diff --git a/web/src/components/l10n/TimezoneSelection.test.tsx b/web/src/components/l10n/TimezoneSelection.test.tsx index 201595f4e7..1077a34cef 100644 --- a/web/src/components/l10n/TimezoneSelection.test.tsx +++ b/web/src/components/l10n/TimezoneSelection.test.tsx @@ -24,7 +24,11 @@ import React from "react"; import TimezoneSelection from "./TimezoneSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; -import { mockNavigateFn, plainRender } from "~/test-utils"; +import { mockNavigateFn, installerRender } from "~/test-utils"; + +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); const timezones = [ { id: "Europe/Berlin", parts: ["Europe", "Berlin"], country: "Germany", utcOffset: 120 }, @@ -70,7 +74,7 @@ afterEach(() => { }); it("allows changing the timezone", async () => { - plainRender(); + installerRender(); const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); const option = await screen.findByText("Europe-Madrid"); @@ -82,7 +86,7 @@ it("allows changing the timezone", async () => { }); it("displays the UTC offset", () => { - plainRender(); + installerRender(); expect(screen.getByText("Australia/Adelaide UTC+9:30")).toBeInTheDocument(); expect(screen.getByText("Europe/Madrid UTC+2")).toBeInTheDocument(); diff --git a/web/src/components/layout/Header.test.tsx b/web/src/components/layout/Header.test.tsx index 1ae19803fc..bd34ec37b8 100644 --- a/web/src/components/layout/Header.test.tsx +++ b/web/src/components/layout/Header.test.tsx @@ -25,17 +25,20 @@ import { screen, within } from "@testing-library/react"; import { installerRender, mockRoutes } from "~/test-utils"; import Header from "./Header"; import { InstallationPhase } from "~/types/status"; +import { Product } from "~/types/software"; -const tumbleweed = { +const tumbleweed: Product = { id: "Tumbleweed", name: "openSUSE Tumbleweed", description: "Tumbleweed description...", + registration: "no", }; -const microos = { +const microos: Product = { id: "MicroOS", name: "openSUSE MicroOS", description: "MicroOS description", + registration: "no", }; let phase: InstallationPhase; diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index cbdc3ce110..09ab2d872b 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -26,6 +26,7 @@ import React from "react"; // icons location. Check the tsconfig.json file to see its value. import AddAPhoto from "@icons/add_a_photo.svg?component"; import Apps from "@icons/apps.svg?component"; +import AppRegistration from "@icons/app_registration.svg?component"; import Badge from "@icons/badge.svg?component"; import Backspace from "@icons/backspace.svg?component"; import CheckCircle from "@icons/check_circle.svg?component"; @@ -91,6 +92,7 @@ import { SiLinux } from "@icons-pack/react-simple-icons"; const icons = { add_a_photo: AddAPhoto, apps: Apps, + app_registration: AppRegistration, badge: Badge, backspace: Backspace, check_circle: CheckCircle, diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index fa3d3c7495..fa094548cc 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -24,25 +24,77 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import Sidebar from "./Sidebar"; +import { Product } from "~/types/software"; +import { useProduct } from "~/queries/software"; jest.mock("~/components/core/ChangeProductLink", () => () =>
ChangeProductLink Mock
); +const tw: Product = { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + registration: "no", +}; + +const sle: Product = { + id: "sle", + name: "SLE", + registration: "mandatory", +}; + +let selectedProduct: Product; + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useProduct: (): ReturnType => { + return { + products: [tw, sle], + selectedProduct, + }; + }, +})); + jest.mock("~/router", () => ({ rootRoutes: () => [ { path: "/", handle: { name: "Main" } }, { path: "/l10n", handle: { name: "L10n" } }, { path: "/hidden" }, + { + path: "/registration", + handle: { name: "Registration", needsRegistrableProduct: true }, + }, ], })); describe("Sidebar", () => { - it("renders a navigation on top of root routes with handle object", () => { - installerRender(); - const mainNavigation = screen.getByRole("navigation"); - const mainNavigationLinks = within(mainNavigation).getAllByRole("link"); - expect(mainNavigationLinks.length).toBe(2); - screen.getByRole("link", { name: "Main" }); - screen.getByRole("link", { name: "L10n" }); + describe("when product is registrable", () => { + beforeEach(() => { + selectedProduct = sle; + }); + + it("renders a navigation including all root routes with handle object", () => { + installerRender(); + const mainNavigation = screen.getByRole("navigation"); + const mainNavigationLinks = within(mainNavigation).getAllByRole("link"); + expect(mainNavigationLinks.length).toBe(3); + screen.getByRole("link", { name: "Main" }); + screen.getByRole("link", { name: "L10n" }); + screen.getByRole("link", { name: "Registration" }); + }); + }); + + describe("when product is not registrable", () => { + beforeEach(() => { + selectedProduct = tw; + }); + + it("renders a navigation including all root routes with handle object, except ones set as needsRegistrableProduct", () => { + installerRender(); + const mainNavigation = screen.getByRole("navigation"); + const mainNavigationLinks = within(mainNavigation).getAllByRole("link"); + expect(mainNavigationLinks.length).toBe(2); + screen.getByRole("link", { name: "Main" }); + screen.getByRole("link", { name: "L10n" }); + }); }); it("mounts core/ChangeProductLink component", () => { diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index aa768d0218..5d446130d9 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -27,10 +27,14 @@ import { Icon } from "~/components/layout"; import { ChangeProductLink } from "~/components/core"; import { rootRoutes } from "~/router"; import { _ } from "~/i18n"; +import { useProduct } from "~/queries/software"; const MainNavigation = (): React.ReactNode => { + const { selectedProduct: product } = useProduct(); + const links = rootRoutes().map((r) => { if (!r.handle) return null; + if (r.handle.needsRegistrableProduct && product.registration === "no") return null; // eslint-disable-next-line agama-i18n/string-literals const name = _(r.handle.name); diff --git a/web/src/components/network/NetworkPage.test.tsx b/web/src/components/network/NetworkPage.test.tsx index e6f0bfae52..841bb245cb 100644 --- a/web/src/components/network/NetworkPage.test.tsx +++ b/web/src/components/network/NetworkPage.test.tsx @@ -26,6 +26,10 @@ import { installerRender } from "~/test-utils"; import NetworkPage from "~/components/network/NetworkPage"; import { Connection, ConnectionMethod, ConnectionStatus, ConnectionType } from "~/types/network"; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + const wiredConnection = new Connection("eth0", { iface: "eth0", method4: ConnectionMethod.MANUAL, diff --git a/web/src/components/overview/OverviewPage.test.tsx b/web/src/components/overview/OverviewPage.test.tsx index 2cef9ae6b6..d37d118670 100644 --- a/web/src/components/overview/OverviewPage.test.tsx +++ b/web/src/components/overview/OverviewPage.test.tsx @@ -22,16 +22,19 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import { OverviewPage } from "~/components/overview"; jest.mock("~/components/overview/L10nSection", () => () =>
Localization Section
); jest.mock("~/components/overview/StorageSection", () => () =>
Storage Section
); jest.mock("~/components/overview/SoftwareSection", () => () =>
Software Section
); +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert
+)); describe("when a product is selected", () => { it("renders the overview page content", async () => { - plainRender(); + installerRender(); await screen.findByText("Localization Section"); await screen.findByText("Storage Section"); await screen.findByText("Software Section"); diff --git a/web/src/components/overview/OverviewPage.tsx b/web/src/components/overview/OverviewPage.tsx index ecbe0c5951..17f0efadc9 100644 --- a/web/src/components/overview/OverviewPage.tsx +++ b/web/src/components/overview/OverviewPage.tsx @@ -21,44 +21,35 @@ */ import React from "react"; -import { Grid, GridItem, Hint, HintBody, Stack } from "@patternfly/react-core"; +import { Grid, GridItem, Stack } from "@patternfly/react-core"; import { Page } from "~/components/core"; import L10nSection from "./L10nSection"; import StorageSection from "./StorageSection"; import SoftwareSection from "./SoftwareSection"; import { _ } from "~/i18n"; -const OverviewSection = () => ( - - - - - - - -); - export default function OverviewPage() { return ( + +

{_("Overview")}

+
+ - - - {_( - "Take your time to check your configuration before starting the installation process.", - )} - - - - - + + + + + + + diff --git a/web/src/components/product/ProductRegistrationAlert.test.tsx b/web/src/components/product/ProductRegistrationAlert.test.tsx new file mode 100644 index 0000000000..856e47737a --- /dev/null +++ b/web/src/components/product/ProductRegistrationAlert.test.tsx @@ -0,0 +1,134 @@ +/* + * 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 { installerRender, mockRoutes } from "~/test-utils"; +import ProductRegistrationAlert from "./ProductRegistrationAlert"; +import { Product, RegistrationInfo } from "~/types/software"; +import { useProduct, useRegistration } from "~/queries/software"; +import { PRODUCT, REGISTRATION, ROOT, USER } from "~/routes/paths"; + +jest.mock("~/components/core/ChangeProductLink", () => () =>
ChangeProductLink Mock
); + +const tw: Product = { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + registration: "no", +}; + +const sle: Product = { + id: "sle", + name: "SLE", + registration: "mandatory", +}; + +let selectedProduct: Product; +let registrationInfoMock: RegistrationInfo; + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useRegistration: (): ReturnType => registrationInfoMock, + useProduct: (): ReturnType => { + return { + products: [tw, sle], + selectedProduct, + }; + }, +})); + +const rendersNothingInSomePaths = () => { + describe.each([ + ["login", ROOT.login], + ["product selection", PRODUCT.changeProduct], + ["product selection progress", PRODUCT.progress], + ["installation progress", ROOT.installationProgress], + ["installation finished", ROOT.installationFinished], + ["root authentication", USER.rootUser.edit], + ])(`but at %s path`, (_, path) => { + beforeEach(() => { + mockRoutes(path); + }); + + it("renders nothing", () => { + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + }); +}; + +describe("ProductRegistrationAlert", () => { + describe("when product is registrable and registration code is not set", () => { + beforeEach(() => { + selectedProduct = sle; + registrationInfoMock = { key: "", email: "" }; + }); + + rendersNothingInSomePaths(); + + it("renders an alert warning about registration required", () => { + installerRender(); + screen.getByRole("heading", { + name: /Warning alert:.*must be registered/, + }); + const link = screen.getByRole("link", { name: "Register it now" }); + expect(link).toHaveAttribute("href", REGISTRATION.root); + }); + + describe("but at registration path already", () => { + beforeEach(() => { + mockRoutes(REGISTRATION.root); + }); + + it("does not render the link to registration", () => { + installerRender(); + screen.getByRole("heading", { + name: /Warning alert:.*must be registered/, + }); + expect(screen.queryAllByRole("link")).toEqual([]); + }); + }); + }); + + describe("when product is registrable and registration code is already set", () => { + beforeEach(() => { + selectedProduct = sle; + registrationInfoMock = { key: "INTERNAL-USE-ONLY-1234-5678", email: "" }; + }); + + it("renders nothing", () => { + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("when product is not registrable", () => { + beforeEach(() => { + selectedProduct = tw; + }); + + it("renders nothing", () => { + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + }); +}); diff --git a/web/src/components/product/ProductRegistrationAlert.tsx b/web/src/components/product/ProductRegistrationAlert.tsx new file mode 100644 index 0000000000..4693a969db --- /dev/null +++ b/web/src/components/product/ProductRegistrationAlert.tsx @@ -0,0 +1,60 @@ +/* + * Copyright (c) [2023-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 { Alert } from "@patternfly/react-core"; +import { useLocation } from "react-router-dom"; +import { Link } from "~/components/core"; +import { useProduct, useRegistration } from "~/queries/software"; +import { REGISTRATION, SUPPORTIVE_PATHS } from "~/routes/paths"; +import { isEmpty } from "~/utils"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; + +const LinkToRegistration = () => { + const location = useLocation(); + + if (location.pathname === REGISTRATION.root) return; + + return ( + + {_("Register it now")} + + ); +}; + +export default function ProductRegistrationAlert() { + const location = useLocation(); + const { selectedProduct: product } = useProduct(); + const registration = useRegistration(); + + // NOTE: it shouldn't be mounted in these paths, but let's prevent rendering + // if so just in case. + if (SUPPORTIVE_PATHS.includes(location.pathname)) return; + if (["no", undefined].includes(product.registration) || !isEmpty(registration.key)) return; + + return ( + + + + ); +} diff --git a/web/src/components/product/ProductRegistrationPage.test.tsx b/web/src/components/product/ProductRegistrationPage.test.tsx new file mode 100644 index 0000000000..8e8832b96d --- /dev/null +++ b/web/src/components/product/ProductRegistrationPage.test.tsx @@ -0,0 +1,175 @@ +/* + * 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 { installerRender } from "~/test-utils"; +import ProductRegistrationPage from "./ProductRegistrationPage"; +import { Product, RegistrationInfo } from "~/types/software"; +import { useProduct, useRegistration } from "~/queries/software"; + +const tw: Product = { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + registration: "no", +}; + +const sle: Product = { + id: "sle", + name: "SLE", + registration: "mandatory", +}; + +let selectedProduct: Product; +let registrationInfoMock: RegistrationInfo; +const registerMutationMock = jest.fn(); +const deregisterMutationMock = jest.fn(); + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useRegisterMutation: () => ({ mutate: registerMutationMock }), + useDeregisterMutation: () => ({ mutate: deregisterMutationMock }), + useRegistration: (): ReturnType => registrationInfoMock, + useProduct: (): ReturnType => { + return { + products: [tw, sle], + selectedProduct, + }; + }, +})); + +describe("ProductRegistrationPage", () => { + describe("when selected product is not registrable", () => { + beforeEach(() => { + selectedProduct = tw; + registrationInfoMock = { key: "", email: "" }; + }); + + it("renders nothing", () => { + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("when selected product is registrable and registration code is not set", () => { + beforeEach(() => { + selectedProduct = sle; + registrationInfoMock = { key: "", email: "" }; + }); + + it("renders ProductRegistrationAlert component", () => { + installerRender(); + screen.getByText("Warning alert:"); + }); + + it("renders a form to allow user registering the product", async () => { + const { user } = installerRender(); + const registrationCodeInput = screen.getByLabelText("Registration code"); + const emailInput = screen.getByRole("textbox", { name: /Email/ }); + const submitButton = screen.getByRole("button", { name: "Register" }); + + await user.type(registrationCodeInput, "INTERNAL-USE-ONLY-1234-5678"); + await user.type(emailInput, "example@company.test"); + await user.click(submitButton); + + expect(registerMutationMock).toHaveBeenCalledWith( + { + email: "example@company.test", + key: "INTERNAL-USE-ONLY-1234-5678", + }, + expect.anything(), + ); + }); + + it.todo("handles and renders errors from server, if any"); + }); + + describe("when selected product is registrable and registration code is set", () => { + beforeEach(() => { + selectedProduct = sle; + registrationInfoMock = { key: "INTERNAL-USE-ONLY-1234-5678", email: "example@company.test" }; + }); + + it("does not render ProductRegistrationAlert component", () => { + installerRender(); + expect(screen.queryByText("Warning alert:")).toBeNull(); + }); + + it("renders registration information with code partially hidden", async () => { + const { user } = installerRender(); + const visibilityCodeToggler = screen.getByRole("button", { name: "Show" }); + screen.getByText(/\*?5678/); + expect(screen.queryByText("INTERNAL-USE-ONLY-1234-5678")).toBeNull(); + expect(screen.queryByText("INTERNAL-USE-ONLY-1234-5678")).toBeNull(); + screen.getByText("example@company.test"); + await user.click(visibilityCodeToggler); + screen.getByText("INTERNAL-USE-ONLY-1234-5678"); + await user.click(visibilityCodeToggler); + expect(screen.queryByText("INTERNAL-USE-ONLY-1234-5678")).toBeNull(); + screen.getByText(/\*?5678/); + }); + + it("allows deregistering the product", async () => { + const { user } = installerRender(); + const deregisterButton = screen.getByRole("button", { name: "deregister" }); + await user.click(deregisterButton); + expect(deregisterMutationMock).toHaveBeenCalled(); + }); + + // describe("but at registration path already", () => { + // beforeEach(() => { + // mockRoutes(REGISTRATION.root); + // }); + // + // it("does not render the link to registration", () => { + // installerRender(); + // screen.getByRole("heading", { + // name: /Warning alert:.*must be registered/, + // }); + // expect(screen.queryAllByRole("link")).toEqual([]); + // }); + // }); + // }); + // + // describe("when product is registrable and registration code is already set", () => { + // beforeEach(() => { + // selectedProduct = sle; + // registrationInfoMock = { key: "INTERNAL-USE-ONLY-1234-5678", email: "" }; + // }); + // + // it("renders nothing", () => { + // const { container } = installerRender(); + // expect(container).toBeEmptyDOMElement(); + // }); + // }); + // + // describe("when product is not registrable", () => { + // beforeEach(() => { + // selectedProduct = tw; + // }); + // + // it("renders nothing", () => { + // const { container } = installerRender(); + // expect(container).toBeEmptyDOMElement(); + // }); + }); +}); diff --git a/web/src/components/product/ProductRegistrationPage.tsx b/web/src/components/product/ProductRegistrationPage.tsx new file mode 100644 index 0000000000..b776faecb2 --- /dev/null +++ b/web/src/components/product/ProductRegistrationPage.tsx @@ -0,0 +1,185 @@ +/* + * Copyright (c) [2023-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, { useState } from "react"; +import { + ActionGroup, + Alert, + Button, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + Form, + FormGroup, + Grid, + Stack, + TextInput, +} from "@patternfly/react-core"; +import { Page, PasswordInput } from "~/components/core"; +import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import { + useProduct, + useRegistration, + useRegisterMutation, + useDeregisterMutation, +} from "~/queries/software"; +import { isEmpty, mask } from "~/utils"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; + +const FORM_ID = "productRegistration"; +const KEY_LABEL = _("Registration code"); +const EMAIL_LABEL = "Email"; + +const RegisteredProductSection = () => { + const { selectedProduct: product } = useProduct(); + const { mutate: deregister } = useDeregisterMutation(); + const registration = useRegistration(); + const [showCode, setShowCode] = useState(false); + const toggleCodeVisibility = () => setShowCode(!showCode); + + const footer = _("For using a different registration code, please %s the product first."); + const deregisterButtonLabel = _("deregister"); + const [footerStart, footerEnd] = footer.split("%s"); + + return ( + + {footerStart}{" "} + {" "} + {footerEnd} +

+ } + > + + + {KEY_LABEL} + + + {showCode ? registration.key : mask(registration.key)} + + + + {!isEmpty(registration.email) && ( + <> + {EMAIL_LABEL} + {registration.email} + + )} + + +
+ ); +}; + +const RegistrationFormSection = () => { + const { mutate: register } = useRegisterMutation(); + const [key, setKey] = useState(""); + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // FIXME: use the right type for AxiosResponse + const onRegisterError = ({ response }) => { + const originalMessage = response.data.message; + const from = originalMessage.indexOf(":") + 1; + setError(originalMessage.slice(from).trim()); + }; + + const submit = async (e: React.SyntheticEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + // @ts-ignore + register({ key, email }, { onError: onRegisterError, onSettled: () => setLoading(false) }); + }; + + // TODO: adjust texts based of registration "type", mandatory or optional + + return ( + +
+ {error && } + + setKey(v)} /> + + + {EMAIL_LABEL} {_("(optional)")} + + } + > + setEmail(v)} /> + + + + + + +
+ ); +}; + +export default function ProductRegistrationPage() { + const { selectedProduct: product } = useProduct(); + const registration = useRegistration(); + + // TODO: render something meaningful instead? "Product not registrable"? + if (product.registration === "no") return; + + return ( + + +

{_("Registration")}

+
+ + + + + {isEmpty(registration.key) ? : } + + + +
+ ); +} diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx index a1682bc38d..0c54144f16 100644 --- a/web/src/components/product/ProductSelectionPage.test.tsx +++ b/web/src/components/product/ProductSelectionPage.test.tsx @@ -27,11 +27,18 @@ import { ProductSelectionPage } from "~/components/product"; import { Product } from "~/types/software"; import { useProduct } from "~/queries/software"; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + +const mockConfigMutation = jest.fn(); + const tumbleweed: Product = { id: "Tumbleweed", name: "openSUSE Tumbleweed", icon: "tumbleweed.svg", description: "Tumbleweed description...", + registration: "no", }; const microOs: Product = { @@ -39,9 +46,9 @@ const microOs: Product = { name: "openSUSE MicroOS", icon: "microos.svg", description: "MicroOS description", + registration: "no", }; -const mockConfigMutation = jest.fn(); let mockSelectedProduct: Product; jest.mock("~/queries/software", () => ({ diff --git a/web/src/components/product/ProductSelectionProgress.test.tsx b/web/src/components/product/ProductSelectionProgress.test.tsx index 082ac388d2..0171fe9bc0 100644 --- a/web/src/components/product/ProductSelectionProgress.test.tsx +++ b/web/src/components/product/ProductSelectionProgress.test.tsx @@ -30,7 +30,7 @@ import { Product } from "~/types/software"; jest.mock("~/components/core/ProgressReport", () => () =>
ProgressReport Mock
); let isBusy = false; -const tumbleweed: Product = { id: "openSUSE", name: "openSUSE Tumbleweed" }; +const tumbleweed: Product = { id: "openSUSE", name: "openSUSE Tumbleweed", registration: "no" }; jest.mock("~/queries/status", () => ({ ...jest.requireActual("~/queries/status"), diff --git a/web/src/components/product/index.ts b/web/src/components/product/index.ts index 1340569f37..fc63c951d0 100644 --- a/web/src/components/product/index.ts +++ b/web/src/components/product/index.ts @@ -22,3 +22,5 @@ export { default as ProductSelectionPage } from "./ProductSelectionPage"; export { default as ProductSelectionProgress } from "./ProductSelectionProgress"; +export { default as ProductRegistrationPage } from "./ProductRegistrationPage"; +export { default as ProductRegistrationAlert } from "./ProductRegistrationAlert"; diff --git a/web/src/components/software/SoftwarePage.test.tsx b/web/src/components/software/SoftwarePage.test.tsx index 88a99d05e3..132d5b0e23 100644 --- a/web/src/components/software/SoftwarePage.test.tsx +++ b/web/src/components/software/SoftwarePage.test.tsx @@ -28,6 +28,10 @@ import testingPatterns from "./patterns.test.json"; import testingProposal from "./proposal.test.json"; import SoftwarePage from "./SoftwarePage"; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + jest.mock("~/queries/issues", () => ({ useIssues: () => [], })); diff --git a/web/src/components/software/SoftwarePatternsSelection.test.tsx b/web/src/components/software/SoftwarePatternsSelection.test.tsx index 1072054b56..e80ecac6b9 100644 --- a/web/src/components/software/SoftwarePatternsSelection.test.tsx +++ b/web/src/components/software/SoftwarePatternsSelection.test.tsx @@ -28,6 +28,10 @@ import SoftwarePatternsSelection from "./SoftwarePatternsSelection"; const onConfigMutationMock = { mutate: jest.fn() }; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + jest.mock("~/queries/software", () => ({ usePatterns: () => testingPatterns, useConfigMutation: () => onConfigMutationMock, diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index 28081f88be..44f3c3d1d8 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -27,7 +27,7 @@ */ import React from "react"; import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import { ProposalPage } from "~/components/storage"; import { ProposalResult, @@ -141,6 +141,6 @@ jest.mock("~/queries/storage", () => ({ })); it("renders the device, settings and result sections", () => { - plainRender(); + installerRender(); screen.findByText("Device"); }); diff --git a/web/src/components/users/RootAuthMethodsPage.test.tsx b/web/src/components/users/RootAuthMethodsPage.test.tsx index 480c097668..153dc8b574 100644 --- a/web/src/components/users/RootAuthMethodsPage.test.tsx +++ b/web/src/components/users/RootAuthMethodsPage.test.tsx @@ -27,6 +27,10 @@ import { RootAuthMethodsPage } from "~/components/users"; const mockRootUserMutation = { mutateAsync: jest.fn() }; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + jest.mock("~/queries/users", () => ({ ...jest.requireActual("~/queries/users"), useRootUserMutation: () => mockRootUserMutation, diff --git a/web/src/queries/software.ts b/web/src/queries/software.ts index 6030489bd7..287154283d 100644 --- a/web/src/queries/software.ts +++ b/web/src/queries/software.ts @@ -33,15 +33,19 @@ import { Pattern, PatternsSelection, Product, + RegistrationInfo, SelectedBy, SoftwareConfig, SoftwareProposal, } from "~/types/software"; import { + deregister, fetchConfig, fetchPatterns, fetchProducts, fetchProposal, + fetchRegistration, + register, updateConfig, } from "~/api/software"; import { QueryHookOptions } from "~/types/queries"; @@ -80,6 +84,14 @@ const selectedProductQuery = () => ({ queryFn: () => fetchConfig().then(({ product }) => product), }); +/** + * Query to retrieve registration info + */ +const registrationQuery = () => ({ + queryKey: ["software/registration"], + queryFn: fetchRegistration, +}); + /** * Query to retrieve available patterns */ @@ -111,6 +123,44 @@ const useConfigMutation = () => { return useMutation(query); }; +/** + * Hook that builds a mutation for registering a product + * + * @note it would trigger a general probing as a side-effect when mutation + * includes a product. + */ +const useRegisterMutation = () => { + const queryClient = useQueryClient(); + + const query = { + mutationFn: register, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["software/registration"] }); + startProbing(); + }, + }; + return useMutation(query); +}; + +/** + * Hook that builds a mutation for deregistering a product + * + * @note it would trigger a general probing as a side-effect when mutation + * includes a product. + */ +const useDeregisterMutation = () => { + const queryClient = useQueryClient(); + + const query = { + mutationFn: deregister, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["software/registration"] }); + startProbing(); + }, + }; + return useMutation(query); +}; + /** * Returns available products and selected one, if any */ @@ -172,6 +222,14 @@ const useProposal = (): SoftwareProposal => { return proposal; }; +/** + * Returns registration info + */ +const useRegistration = (): RegistrationInfo => { + const { data: registration } = useSuspenseQuery(registrationQuery()); + return registration; +}; + /** * Hook that returns a useEffect to listen for software proposal events * @@ -226,4 +284,7 @@ export { useProductChanges, useProposal, useProposalChanges, + useRegistration, + useRegisterMutation, + useDeregisterMutation, }; diff --git a/web/src/router.tsx b/web/src/router.tsx index 8a94477885..55ec7f0ce2 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -30,6 +30,7 @@ import { OverviewPage } from "~/components/overview"; import l10nRoutes from "~/routes/l10n"; import networkRoutes from "~/routes/network"; import productsRoutes from "~/routes/products"; +import registrationRoutes from "~/routes/registration"; import storageRoutes from "~/routes/storage"; import softwareRoutes from "~/routes/software"; import usersRoutes from "~/routes/users"; @@ -43,6 +44,7 @@ const rootRoutes = () => [ element: , handle: { name: N_("Overview"), icon: "list_alt" }, }, + registrationRoutes(), l10nRoutes(), networkRoutes(), storageRoutes(), diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 22cdba06e2..efe67522a4 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -39,6 +39,10 @@ const PRODUCT = { progress: "/products/progress", }; +const REGISTRATION = { + root: "/registration", +}; + const ROOT = { root: "/", login: "/login", @@ -78,4 +82,13 @@ const STORAGE = { }, }; -export { L10N, NETWORK, PRODUCT, ROOT, SOFTWARE, STORAGE, USER }; +const SUPPORTIVE_PATHS = [ + ROOT.login, + PRODUCT.changeProduct, + PRODUCT.progress, + ROOT.installationProgress, + ROOT.installationFinished, + USER.rootUser.edit, +]; + +export { L10N, NETWORK, PRODUCT, REGISTRATION, ROOT, SOFTWARE, STORAGE, USER, SUPPORTIVE_PATHS }; diff --git a/web/src/routes/registration.tsx b/web/src/routes/registration.tsx new file mode 100644 index 0000000000..db27ad5037 --- /dev/null +++ b/web/src/routes/registration.tsx @@ -0,0 +1,40 @@ +/* + * 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 { ProductRegistrationPage } from "~/components/product"; +import { Route } from "~/types/routes"; +import { REGISTRATION as PATHS } from "~/routes/paths"; +import { N_ } from "~/i18n"; + +const routes = (): Route => ({ + path: PATHS.root, + handle: { name: N_("Registration"), icon: "app_registration", needsRegistrableProduct: true }, + children: [ + { + index: true, + element: , + }, + ], +}); + +export default routes; diff --git a/web/src/types/registration.ts b/web/src/types/registration.ts index 4e70d22974..bb31e80435 100644 --- a/web/src/types/registration.ts +++ b/web/src/types/registration.ts @@ -22,7 +22,7 @@ type Registration = { /** Registration requirement (i.e., "not-required", "optional", "mandatory") */ - requirement: string; + requirement: "no" | "optional" | "mandatory"; /** Registration code, if any */ code?: string; /** Registration email, if any */ diff --git a/web/src/types/routes.ts b/web/src/types/routes.ts index 11f2135547..fe5b85ea71 100644 --- a/web/src/types/routes.ts +++ b/web/src/types/routes.ts @@ -29,6 +29,8 @@ type RouteHandle = { title?: string; /** Icon for representing the route in some places, like a menu entry */ icon?: string; + /** Whether the route link will be rendered for registrable products only */ + needsRegistrableProduct?: boolean; }; type Route = RouteObject & { handle?: RouteHandle }; diff --git a/web/src/types/software.ts b/web/src/types/software.ts index 822d60855a..f35a9a609e 100644 --- a/web/src/types/software.ts +++ b/web/src/types/software.ts @@ -41,6 +41,8 @@ type Product = { description?: string; /** Product icon (e.g., "default.svg") */ icon?: string; + /** If product is registrable or not */ + registration: "no" | "optional" | "mandatory"; }; type PatternsSelection = { [key: string]: SelectedBy }; @@ -76,5 +78,17 @@ type Pattern = { selectedBy?: SelectedBy; }; +type RegistrationInfo = { + key: string; + email?: string; +}; + export { SelectedBy }; -export type { Pattern, PatternsSelection, Product, SoftwareConfig, SoftwareProposal }; +export type { + Pattern, + PatternsSelection, + Product, + SoftwareConfig, + RegistrationInfo, + SoftwareProposal, +}; diff --git a/web/src/utils.ts b/web/src/utils.ts index 3290d65117..81ab64c3f8 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -402,6 +402,11 @@ const timezoneTime = (timezone: string, date: Date = new Date()): string | undef } }; +const mask = (value, visible = 4, character = "*") => { + const regex = new RegExp(`.(?=(.{${visible}}))`, "g"); + return value.replace(regex, character); +}; + export { noop, identity, @@ -422,4 +427,5 @@ export { localConnection, slugify, timezoneTime, + mask, };