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}{" "}
+ deregister()} variant="link" isInline>
+ {deregisterButtonLabel}
+ {" "}
+ {footerEnd}
+
+ }
+ >
+
+
+ {KEY_LABEL}
+
+
+ {showCode ? registration.key : mask(registration.key)}
+
+ {showCode ? _("Hide") : _("Show")}
+
+
+
+ {!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 (
+
+
+
+ );
+};
+
+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,
};