diff --git a/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml index c5e07f0f2d..fcb0439338 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml @@ -1,10 +1,6 @@ - + - - - - - @@ -26,10 +22,22 @@ + + + + + + + + + + + + - + @@ -43,7 +51,7 @@ - + @@ -53,11 +61,7 @@ - - + - - - diff --git a/doc/dbus/org.opensuse.Agama.Users1.doc.xml b/doc/dbus/org.opensuse.Agama.Users1.doc.xml index 334469be64..50c3971583 100644 --- a/doc/dbus/org.opensuse.Agama.Users1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Users1.doc.xml @@ -2,49 +2,23 @@ "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> - - + - - - - - - + @@ -54,25 +28,7 @@ - - - - - - - - + diff --git a/rust/agama-lib/src/users.rs b/rust/agama-lib/src/users.rs index 092800dc30..b3cfe15ca2 100644 --- a/rust/agama-lib/src/users.rs +++ b/rust/agama-lib/src/users.rs @@ -27,7 +27,7 @@ pub mod proxies; mod settings; mod store; -pub use client::{FirstUser, UsersClient}; +pub use client::{FirstUser, RootUser, UsersClient}; pub use http_client::UsersHTTPClient; pub use settings::{FirstUserSettings, RootUserSettings, UserSettings}; pub use store::UsersStore; diff --git a/rust/agama-lib/src/users/client.rs b/rust/agama-lib/src/users/client.rs index 6faafd8961..d3bace6b63 100644 --- a/rust/agama-lib/src/users/client.rs +++ b/rust/agama-lib/src/users/client.rs @@ -20,7 +20,7 @@ //! Implements a client to access Agama's users service. -use super::proxies::{FirstUser as FirstUserFromDBus, Users1Proxy}; +use super::proxies::{FirstUser as FirstUserFromDBus, RootUser as RootUserFromDBus, Users1Proxy}; use crate::error::ServiceError; use serde::{Deserialize, Serialize}; use zbus::Connection; @@ -37,8 +37,6 @@ pub struct FirstUser { pub password: String, /// Whether the password is hashed (true) or is plain text (false) pub hashed_password: bool, - /// Whether auto-login should enabled or not - pub autologin: bool, } impl FirstUser { @@ -49,7 +47,42 @@ impl FirstUser { user_name: data.1, password: data.2, hashed_password: data.3, - autologin: data.4, + }) + } +} + +/// Represents the settings for the first user +#[derive(Serialize, Deserialize, Clone, Debug, Default, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RootUser { + /// Root user password + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + /// Whether the password is hashed (true) or is plain text (false or None) + pub hashed_password: Option, + /// SSH public key + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_public_key: Option, +} + +impl RootUser { + pub fn from_dbus(dbus_data: RootUserFromDBus) -> zbus::Result { + let password = if dbus_data.0.is_empty() { + None + } else { + Some(dbus_data.0) + }; + + let ssh_public_key = if dbus_data.2.is_empty() { + None + } else { + Some(dbus_data.2) + }; + + Ok(Self { + password, + hashed_password: Some(dbus_data.1), + ssh_public_key, }) } } @@ -72,6 +105,10 @@ impl<'a> UsersClient<'a> { FirstUser::from_dbus(self.users_proxy.first_user().await) } + pub async fn root_user(&self) -> zbus::Result { + RootUser::from_dbus(self.users_proxy.root_user().await?) + } + /// SetRootPassword method pub async fn set_root_password(&self, value: &str, hashed: bool) -> Result { Ok(self.users_proxy.set_root_password(value, hashed).await?) @@ -81,16 +118,6 @@ impl<'a> UsersClient<'a> { Ok(self.users_proxy.remove_root_password().await?) } - /// Whether the root password is set or not - pub async fn is_root_password(&self) -> Result { - Ok(self.users_proxy.root_password_set().await?) - } - - /// Returns the SSH key for the root user - pub async fn root_ssh_key(&self) -> zbus::Result { - self.users_proxy.root_sshkey().await - } - /// SetRootSSHKey method pub async fn set_root_sshkey(&self, value: &str) -> Result { Ok(self.users_proxy.set_root_sshkey(value).await?) @@ -107,7 +134,6 @@ impl<'a> UsersClient<'a> { &first_user.user_name, &first_user.password, first_user.hashed_password, - first_user.autologin, std::collections::HashMap::new(), ) .await diff --git a/rust/agama-lib/src/users/http_client.rs b/rust/agama-lib/src/users/http_client.rs index 1008ed48d8..8bee230a43 100644 --- a/rust/agama-lib/src/users/http_client.rs +++ b/rust/agama-lib/src/users/http_client.rs @@ -18,8 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::client::FirstUser; -use crate::users::model::{RootConfig, RootPatchSettings}; +use super::client::{FirstUser, RootUser}; +use crate::users::model::RootPatchSettings; use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; pub struct UsersHTTPClient { @@ -46,21 +46,15 @@ impl UsersHTTPClient { result } - async fn root_config(&self) -> Result { + pub async fn root_user(&self) -> Result { self.client.get("/users/root").await } - /// Whether the root password is set or not - pub async fn is_root_password(&self) -> Result { - let root_config = self.root_config().await?; - Ok(root_config.password) - } - /// SetRootPassword method. /// Returns 0 if successful (always, for current backend) pub async fn set_root_password(&self, value: &str, hashed: bool) -> Result { let rps = RootPatchSettings { - sshkey: None, + ssh_public_key: None, password: Some(value.to_owned()), hashed_password: Some(hashed), }; @@ -68,17 +62,11 @@ impl UsersHTTPClient { Ok(ret) } - /// Returns the SSH key for the root user - pub async fn root_ssh_key(&self) -> Result { - let root_config = self.root_config().await?; - Ok(root_config.sshkey) - } - /// SetRootSSHKey method. /// Returns 0 if successful (always, for current backend) pub async fn set_root_sshkey(&self, value: &str) -> Result { let rps = RootPatchSettings { - sshkey: Some(value.to_owned()), + ssh_public_key: Some(value.to_owned()), password: None, hashed_password: None, }; diff --git a/rust/agama-lib/src/users/model.rs b/rust/agama-lib/src/users/model.rs index 90e07fecfe..ce71c9d003 100644 --- a/rust/agama-lib/src/users/model.rs +++ b/rust/agama-lib/src/users/model.rs @@ -20,19 +20,11 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RootConfig { - /// returns if password for root is set or not - pub password: bool, - /// empty string mean no sshkey is specified - pub sshkey: String, -} - #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct RootPatchSettings { /// empty string here means remove ssh key for root - pub sshkey: Option, + pub ssh_public_key: Option, /// empty string here means remove password for root pub password: Option, /// specify if patched password is provided in plain text (default) or hashed diff --git a/rust/agama-lib/src/users/proxies.rs b/rust/agama-lib/src/users/proxies.rs index f75921b2ee..ecfc1c246c 100644 --- a/rust/agama-lib/src/users/proxies.rs +++ b/rust/agama-lib/src/users/proxies.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -31,8 +31,8 @@ //! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the //! following zbus API can be used: //! -//! * [`zbus::fdo::IntrospectableProxy`] //! * [`zbus::fdo::PropertiesProxy`] +//! * [`zbus::fdo::IntrospectableProxy`] //! //! Consequently `zbus-xmlgen` did not generate code for the above interfaces. //! @@ -48,7 +48,6 @@ use zbus::proxy; /// * user name /// * password /// * hashed_password (true = hashed, false = plain text) -/// * auto-login (enabled or not) /// * some optional and additional data // NOTE: Manually added to this file. pub type FirstUser = ( @@ -56,10 +55,18 @@ pub type FirstUser = ( String, String, bool, - bool, std::collections::HashMap, ); +/// Root user as it comes from D-Bus. +/// +/// It is composed of: +/// +/// * password (an empty string if it is not set) +/// * hashed_password (true = hashed, false = plain text) +/// * SSH public key +pub type RootUser = (String, bool, String); + #[proxy( default_service = "org.opensuse.Agama.Manager1", default_path = "/org/opensuse/Agama/Users1", @@ -80,7 +87,6 @@ pub trait Users1 { user_name: &str, password: &str, hashed_password: bool, - auto_login: bool, data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, ) -> zbus::Result<(bool, Vec)>; @@ -98,11 +104,7 @@ pub trait Users1 { #[zbus(property)] fn first_user(&self) -> zbus::Result; - /// RootPasswordSet property + /// RootUser property #[zbus(property)] - fn root_password_set(&self) -> zbus::Result; - - /// RootSSHKey property - #[zbus(property, name = "RootSSHKey")] - fn root_sshkey(&self) -> zbus::Result; + fn root_user(&self) -> zbus::Result; } diff --git a/rust/agama-lib/src/users/settings.rs b/rust/agama-lib/src/users/settings.rs index 18da06a493..5aa359958a 100644 --- a/rust/agama-lib/src/users/settings.rs +++ b/rust/agama-lib/src/users/settings.rs @@ -45,8 +45,6 @@ pub struct FirstUserSettings { pub password: Option, /// Whether the password is hashed or is plain text pub hashed_password: Option, - /// Whether auto-login should enabled or not - pub autologin: Option, } /// Root user settings diff --git a/rust/agama-lib/src/users/store.rs b/rust/agama-lib/src/users/store.rs index 77995a027f..5235a4b66e 100644 --- a/rust/agama-lib/src/users/store.rs +++ b/rust/agama-lib/src/users/store.rs @@ -44,16 +44,17 @@ impl UsersStore { let first_user = self.users_client.first_user().await?; let first_user = FirstUserSettings { user_name: Some(first_user.user_name), - autologin: Some(first_user.autologin), full_name: Some(first_user.full_name), password: Some(first_user.password), hashed_password: Some(first_user.hashed_password), }; - let mut root_user = RootUserSettings::default(); - let ssh_public_key = self.users_client.root_ssh_key().await?; - if !ssh_public_key.is_empty() { - root_user.ssh_public_key = Some(ssh_public_key) - } + let root_user = self.users_client.root_user().await?; + let root_user = RootUserSettings { + password: root_user.password, + hashed_password: root_user.hashed_password, + ssh_public_key: root_user.ssh_public_key, + }; + Ok(UserSettings { first_user: Some(first_user), root: Some(root_user), @@ -76,7 +77,6 @@ impl UsersStore { let first_user = FirstUser { user_name: settings.user_name.clone().unwrap_or_default(), full_name: settings.full_name.clone().unwrap_or_default(), - autologin: settings.autologin.unwrap_or_default(), password: settings.password.clone().unwrap_or_default(), hashed_password: settings.hashed_password.unwrap_or_default(), }; @@ -128,8 +128,7 @@ mod test { "fullName": "Tux", "userName": "tux", "password": "fish", - "hashedPassword": false, - "autologin": true + "hashedPassword": false }"#, ); }); @@ -139,8 +138,9 @@ mod test { .header("content-type", "application/json") .body( r#"{ - "sshkey": "keykeykey", - "password": true + "sshPublicKey": "keykeykey", + "password": "nots3cr3t", + "hashedPassword": false }"#, ); }); @@ -154,12 +154,11 @@ mod test { user_name: Some("tux".to_owned()), password: Some("fish".to_owned()), hashed_password: Some(false), - autologin: Some(true), }; let root_user = RootUserSettings { // FIXME this is weird: no matter what HTTP reports, we end up with None - password: None, - hashed_password: None, + password: Some("nots3cr3t".to_owned()), + hashed_password: Some(false), ssh_public_key: Some("keykeykey".to_owned()), }; let expected = UserSettings { @@ -184,7 +183,7 @@ mod test { when.method(PUT) .path("/api/users/first") .header("content-type", "application/json") - .body(r#"{"fullName":"Tux","userName":"tux","password":"fish","hashedPassword":false,"autologin":true}"#); + .body(r#"{"fullName":"Tux","userName":"tux","password":"fish","hashedPassword":false}"#); then.status(200); }); // note that we use 2 requests for root @@ -192,14 +191,14 @@ mod test { when.method(PATCH) .path("/api/users/root") .header("content-type", "application/json") - .body(r#"{"sshkey":null,"password":"1234","hashedPassword":false}"#); + .body(r#"{"sshPublicKey":null,"password":"1234","hashedPassword":false}"#); then.status(200).body("0"); }); let root_mock2 = server.mock(|when, then| { when.method(PATCH) .path("/api/users/root") .header("content-type", "application/json") - .body(r#"{"sshkey":"keykeykey","password":null,"hashedPassword":null}"#); + .body(r#"{"sshPublicKey":"keykeykey","password":null,"hashedPassword":null}"#); then.status(200).body("0"); }); let url = server.url("/api"); @@ -211,7 +210,6 @@ mod test { user_name: Some("tux".to_owned()), password: Some("fish".to_owned()), hashed_password: Some(false), - autologin: Some(true), }; let root_user = RootUserSettings { password: Some("1234".to_owned()), diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs index 260ec35133..0b7912e74e 100644 --- a/rust/agama-server/src/users/web.rs +++ b/rust/agama-server/src/users/web.rs @@ -33,11 +33,7 @@ use crate::{ }; use agama_lib::{ error::ServiceError, - users::{ - model::{RootConfig, RootPatchSettings}, - proxies::Users1Proxy, - FirstUser, UsersClient, - }, + users::{model::RootPatchSettings, proxies::Users1Proxy, FirstUser, RootUser, UsersClient}, }; use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use tokio_stream::{Stream, StreamExt}; @@ -54,23 +50,15 @@ struct UsersState<'a> { /// * `connection`: D-Bus connection to listen for events. pub async fn users_streams(dbus: zbus::Connection) -> Result { const FIRST_USER_ID: &str = "first_user"; - const ROOT_PASSWORD_ID: &str = "root_password"; - const ROOT_SSHKEY_ID: &str = "root_sshkey"; - // here we have three streams, but only two events. Reason is - // that we have three streams from dbus about property change - // and unify two root user properties into single event to http API + const ROOT_USER_ID: &str = "root_user"; let result: EventStreams = vec![ ( FIRST_USER_ID, Box::pin(first_user_changed_stream(dbus.clone()).await?), ), ( - ROOT_PASSWORD_ID, - Box::pin(root_password_changed_stream(dbus.clone()).await?), - ), - ( - ROOT_SSHKEY_ID, - Box::pin(root_ssh_key_changed_stream(dbus.clone()).await?), + ROOT_USER_ID, + Box::pin(root_user_changed_stream(dbus.clone()).await?), ), ]; @@ -91,7 +79,6 @@ async fn first_user_changed_stream( user_name: user.1, password: user.2, hashed_password: user.3, - autologin: user.4, }; return Some(Event::FirstUserChanged(user_struct)); } @@ -101,39 +88,18 @@ async fn first_user_changed_stream( Ok(stream) } -async fn root_password_changed_stream( +async fn root_user_changed_stream( dbus: zbus::Connection, ) -> Result + Send, Error> { let proxy = Users1Proxy::new(&dbus).await?; let stream = proxy - .receive_root_password_set_changed() + .receive_root_user_changed() .await .then(|change| async move { - if let Ok(is_set) = change.get().await { - return Some(Event::RootChanged { - password: Some(is_set), - sshkey: None, - }); - } - None - }) - .filter_map(|e| e); - Ok(stream) -} - -async fn root_ssh_key_changed_stream( - dbus: zbus::Connection, -) -> Result + Send, Error> { - let proxy = Users1Proxy::new(&dbus).await?; - let stream = proxy - .receive_root_sshkey_changed() - .await - .then(|change| async move { - if let Ok(key) = change.get().await { - return Some(Event::RootChanged { - password: None, - sshkey: Some(key), - }); + if let Ok(user) = change.get().await { + if let Ok(root) = RootUser::from_dbus(user) { + return Some(Event::RootUserChanged(root)); + } } None }) @@ -232,7 +198,7 @@ async fn patch_root( Json(config): Json, ) -> Result { let mut retcode1 = 0; - if let Some(key) = config.sshkey { + if let Some(key) = config.ssh_public_key { retcode1 = state.users.set_root_sshkey(&key).await?; } @@ -258,13 +224,10 @@ async fn patch_root( path = "/root", context_path = "/api/users", responses( - (status = 200, description = "Configuration for the root user", body = RootConfig), + (status = 200, description = "Configuration for the root user", body = RootUser), (status = 400, description = "The D-Bus service could not perform the action"), ) )] -async fn get_root_config(State(state): State>) -> Result, Error> { - let password = state.users.is_root_password().await?; - let sshkey = state.users.root_ssh_key().await?; - let config = RootConfig { password, sshkey }; - Ok(Json(config)) +async fn get_root_config(State(state): State>) -> Result, Error> { + Ok(Json(state.users.root_user().await?)) } diff --git a/rust/agama-server/src/web/docs/users.rs b/rust/agama-server/src/web/docs/users.rs index 42034d58a9..4f32219cb4 100644 --- a/rust/agama-server/src/web/docs/users.rs +++ b/rust/agama-server/src/web/docs/users.rs @@ -45,7 +45,7 @@ impl ApiDocBuilder for UsersApiDocBuilder { fn components(&self) -> utoipa::openapi::Components { ComponentsBuilder::new() .schema_from::() - .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema( diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index 1fb4d08697..11f5da9ae0 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -33,7 +33,7 @@ use agama_lib::{ }, ISCSINode, }, - users::FirstUser, + users::{FirstUser, RootUser}, }; use serde::Serialize; use std::collections::HashMap; @@ -64,10 +64,7 @@ pub enum Event { }, RegistrationChanged, FirstUserChanged(FirstUser), - RootChanged { - password: Option, - sshkey: Option, - }, + RootUserChanged(RootUser), NetworkChange { #[serde(flatten)] change: NetworkChange, diff --git a/service/lib/agama/dbus/users.rb b/service/lib/agama/dbus/users.rb index 57b30a0b82..27fafa3993 100644 --- a/service/lib/agama/dbus/users.rb +++ b/service/lib/agama/dbus/users.rb @@ -59,22 +59,20 @@ def issues private_constant :USERS_INTERFACE FUSER_SIG = "in FullName:s, in UserName:s, in Password:s, in HashedPassword:b, " \ - "in AutoLogin:b, in data:a{sv}" + "in data:a{sv}" private_constant :FUSER_SIG dbus_interface USERS_INTERFACE do - dbus_reader :root_password_set, "b" + dbus_reader :root_user, "(sbs)" - dbus_reader :root_ssh_key, "s", dbus_name: "RootSSHKey" - - dbus_reader :first_user, "(sssbba{sv})" + dbus_reader :first_user, "(sssba{sv})" dbus_method :SetRootPassword, "in Value:s, in Hashed:b, out result:u" do |value, hashed| logger.info "Setting Root Password" backend.assign_root_password(value, hashed) - dbus_properties_changed(USERS_INTERFACE, { "RootPasswordSet" => !value.empty? }, []) + dbus_properties_changed(USERS_INTERFACE, { "RootUser" => root_user }, []) 0 end @@ -82,7 +80,7 @@ def issues logger.info "Clearing the root password" backend.remove_root_password - dbus_properties_changed(USERS_INTERFACE, { "RootPasswordSet" => backend.root_password? }, + dbus_properties_changed(USERS_INTERFACE, { "RootUser" => root_user }, []) 0 end @@ -91,18 +89,17 @@ def issues logger.info "Setting Root ssh key" backend.root_ssh_key = (value) - dbus_properties_changed(USERS_INTERFACE, { "RootSSHKey" => value }, []) + dbus_properties_changed(USERS_INTERFACE, { "RootUser" => root_user }, []) 0 end - dbus_method :SetFirstUser, - # It returns an Struct with the first field with the result of the operation as a boolean - # and the second parameter as an array of issues found in case of failure - FUSER_SIG + ", out result:(bas)" do - |full_name, user_name, password, hashed_password, auto_login, data| + # It returns an Struct with the first field with the result of the operation as a boolean + # and the second parameter as an array of issues found in case of failure + dbus_method :SetFirstUser, FUSER_SIG + ", out result:(bas)" do |full_name, user_name, + password, hashed_password, data| logger.info "Setting first user #{full_name}" user_issues = backend.assign_first_user(full_name, user_name, password, - hashed_password, auto_login, data) + hashed_password, data) if user_issues.empty? dbus_properties_changed(USERS_INTERFACE, { "FirstUser" => first_user }, []) @@ -129,29 +126,30 @@ def issues end end - def root_ssh_key - backend.root_ssh_key + def root_user + root = backend.root_user + + [ + root.password_content || "", + root.password&.value&.encrypted?, + root.authorized_keys.first || "" + ] end def first_user user = backend.first_user - return ["", "", "", false, false, {}] unless user + return ["", "", "", false, {}] unless user [ user.full_name, user.name, user.password_content || "", user.password&.value&.encrypted? || false, - backend.autologin?(user), {} ] end - def root_password_set - backend.root_password? - end - private # @return [Agama::Users] diff --git a/service/lib/agama/users.rb b/service/lib/agama/users.rb index 7fa0f921de..b47eaac915 100644 --- a/service/lib/agama/users.rb +++ b/service/lib/agama/users.rb @@ -42,24 +42,11 @@ def initialize(logger) update_issues end - def root_ssh_key - root_user.authorized_keys.first || "" - end - def root_ssh_key=(value) root_user.authorized_keys = [value] # just one supported for now update_issues end - # NOTE: the root user is created if it does not exist - def root_password? - !!root_user.password_content - end - - def root_ssh_key? - !root_ssh_key.empty? - end - def assign_root_password(value, hashed) pwd = if hashed Y2Users::Password.create_encrypted(value) @@ -72,12 +59,18 @@ def assign_root_password(value, hashed) update_issues end - # Whether the given user is configured for autologin + # Root user # - # @param [Y2Users::User] user - # @return [Boolean] - def autologin?(user) - config.login.autologin_user == user + # @return [Y2Users::User] + def root_user + return @root_user if @root_user + + @root_user = config.users.root + return @root_user if @root_user + + @root_user = Y2Users::User.create_root + config.attach(@root_user) + @root_user end # First created user @@ -100,10 +93,9 @@ def remove_root_password # @param user_name [String] # @param password [String] # @param hashed_password [Boolean] true = hashed password, false = plain text password - # @param auto_login [Boolean] # @param _data [Hash] # @return [Array] the list of fatal issues found - def assign_first_user(full_name, user_name, password, hashed_password, auto_login, _data) + def assign_first_user(full_name, user_name, password, hashed_password, _data) remove_first_user user = Y2Users::User.new(user_name) @@ -118,8 +110,6 @@ def assign_first_user(full_name, user_name, password, hashed_password, auto_logi return fatal_issues.map(&:message) unless fatal_issues.empty? config.attach(user) - config.login ||= Y2Users::LoginConfig.new - config.login.autologin_user = auto_login ? user : nil update_issues [] end @@ -190,15 +180,17 @@ def config @config end - def root_user - return @root_user if @root_user + # NOTE: the root user is created if it does not exist + def root_password? + !!root_user.password_content + end - @root_user = config.users.root - return @root_user if @root_user + def root_ssh_key + root_user.authorized_keys.first || "" + end - @root_user = Y2Users::User.create_root - config.attach(@root_user) - @root_user + def root_ssh_key? + !root_ssh_key.empty? end end end diff --git a/service/test/agama/dbus/users_test.rb b/service/test/agama/dbus/users_test.rb index 7dc02cf856..4e60be3eae 100644 --- a/service/test/agama/dbus/users_test.rb +++ b/service/test/agama/dbus/users_test.rb @@ -70,7 +70,7 @@ let(:user) { nil } it "returns default data" do - expect(subject.first_user).to eq(["", "", "", false, false, {}]) + expect(subject.first_user).to eq(["", "", "", false, {}]) end end @@ -84,12 +84,8 @@ password_content: password.value.to_s) end - before do - allow(backend).to receive(:autologin?).with(user).and_return(true) - end - it "returns the first user data" do - expect(subject.first_user).to eq(["Test user", "test", password.value.to_s, true, true, {}]) + expect(subject.first_user).to eq(["Test user", "test", password.value.to_s, true, {}]) end end end diff --git a/service/test/agama/users_test.rb b/service/test/agama/users_test.rb index 38b93ae1c9..848c24934e 100644 --- a/service/test/agama/users_test.rb +++ b/service/test/agama/users_test.rb @@ -37,6 +37,13 @@ describe "#assign_root_password" do let(:root_user) { instance_double(Y2Users::User) } + describe "#root_user" do + it "returns the root user" do + root = subject.root_user + expect(root.name).to eq("root") + end + end + context "when the password is hashed" do it "sets the password as hashed" do subject.assign_root_password("hashed", true) @@ -57,31 +64,17 @@ describe "#remove_root_password" do it "removes the password" do subject.assign_root_password("12345", false) - expect { subject.remove_root_password }.to change { subject.root_password? } - .from(true).to(false) - end - end - - describe "#root_password?" do - it "returns true if the root password is set" do - subject.assign_root_password("12345", false) - expect(subject.root_password?).to eq(true) - end - - it "returns false if the root password is not set" do - expect(subject.root_password?).to eq(false) - end - - it "returns true if the root password is set to nil" do - subject.assign_root_password("", false) - expect(subject.root_password?).to eq(false) + root = subject.root_user + expect(root.password).to be_kind_of(Y2Users::Password) + subject.remove_root_password + expect(root.password).to be_nil end end describe "#assign_first_user" do context "when the options given do not present any issue" do it "adds the user to the user's configuration" do - subject.assign_first_user("Jane Doe", "jane", "12345", false, false, {}) + subject.assign_first_user("Jane Doe", "jane", "12345", false, {}) user = users_config.users.by_name("jane") expect(user.full_name).to eq("Jane Doe") expect(user.password).to eq(Y2Users::Password.create_plain("12345")) @@ -89,11 +82,11 @@ context "when a first user exists" do before do - subject.assign_first_user("Jane Doe", "jane", "12345", false, false, {}) + subject.assign_first_user("Jane Doe", "jane", "12345", false, {}) end it "replaces the user with the new one" do - subject.assign_first_user("John Doe", "john", "12345", false, false, {}) + subject.assign_first_user("John Doe", "john", "12345", false, {}) user = users_config.users.by_name("jane") expect(user).to be_nil @@ -104,23 +97,23 @@ end it "returns an empty array of issues" do - issues = subject.assign_first_user("Jane Doe", "jane", "12345", false, false, {}) + issues = subject.assign_first_user("Jane Doe", "jane", "12345", false, {}) expect(issues).to be_empty end end context "when the given arguments presents some critical error" do it "does not add the user to the config" do - subject.assign_first_user("Jonh Doe", "john", "", false, false, {}) + subject.assign_first_user("Jonh Doe", "john", "", false, {}) user = users_config.users.by_name("john") expect(user).to be_nil - subject.assign_first_user("Ldap user", "ldap", "12345", false, false, {}) + subject.assign_first_user("Ldap user", "ldap", "12345", false, {}) user = users_config.users.by_name("ldap") expect(user).to be_nil end it "returns an array with all the issues" do - issues = subject.assign_first_user("Root user", "root", "12345", false, false, {}) + issues = subject.assign_first_user("Root user", "root", "12345", false, {}) expect(issues.size).to eql(1) end end @@ -128,7 +121,7 @@ describe "#remove_first_user" do before do - subject.assign_first_user("Jane Doe", "jane", "12345", false, false, {}) + subject.assign_first_user("Jane Doe", "jane", "12345", false, {}) end it "removes the already defined first user" do @@ -156,7 +149,7 @@ end it "writes system and installer defined users" do - subject.assign_first_user("Jane Doe", "jane", "12345", false, false, {}) + subject.assign_first_user("Jane Doe", "jane", "12345", false, {}) expect(Y2Users::Linux::Writer).to receive(:new) do |target_config, _old_config| user_names = target_config.users.map(&:name) @@ -196,7 +189,7 @@ context "when a first user is defined" do before do - subject.assign_first_user("Jane Doe", "jdoe", "123456", false, false, {}) + subject.assign_first_user("Jane Doe", "jdoe", "123456", false, {}) end it "returns an empty list" do diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 2715aac301..8eb0297466 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -27,7 +27,6 @@ import App from "./App"; import { InstallationPhase } from "./types/status"; import { createClient } from "~/client"; import { Product } from "./types/software"; -import { RootUser } from "./types/users"; jest.mock("~/client"); @@ -46,7 +45,6 @@ const microos: Product = { id: "Leap Micro", name: "openSUSE Micro", registratio // list of available products let mockProducts: Product[]; let mockSelectedProduct: Product; -let mockRootUser: RootUser; jest.mock("~/queries/software", () => ({ ...jest.requireActual("~/queries/software"), @@ -75,11 +73,6 @@ jest.mock("~/queries/storage", () => ({ useDeprecatedChanges: () => jest.fn(), })); -jest.mock("~/queries/users", () => ({ - ...jest.requireActual("~/queries/storage"), - useRootUser: () => mockRootUser, -})); - const mockClientStatus = { phase: InstallationPhase.Startup, isBusy: true, @@ -110,7 +103,6 @@ describe("App", () => { }); mockProducts = [tumbleweed, microos]; - mockRootUser = { password: true, hashedPassword: false, sshkey: "FAKE-SSH-KEY" }; }); afterEach(() => { @@ -163,42 +155,9 @@ describe("App", () => { mockClientStatus.isBusy = false; }); - describe("when there are no authentication method for root user", () => { - beforeEach(() => { - mockRootUser = { password: false, hashedPassword: false, sshkey: "" }; - }); - - it("redirects to root user edition", async () => { - installerRender(, { withL10n: true }); - await screen.findByText("Navigating to /users/root/edit"); - }); - }); - - describe("when only root password is set", () => { - beforeEach(() => { - mockRootUser = { password: true, hashedPassword: false, sshkey: "" }; - }); - it("renders the application content", async () => { - installerRender(, { withL10n: true }); - await screen.findByText(/Outlet Content/); - }); - }); - - describe("when only root SSH public key is set", () => { - beforeEach(() => { - mockRootUser = { password: false, hashedPassword: false, sshkey: "FAKE-SSH-KEY" }; - }); - it("renders the application content", async () => { - installerRender(, { withL10n: true }); - await screen.findByText(/Outlet Content/); - }); - }); - - describe("when root password and SSH public key are set", () => { - it("renders the application content", async () => { - installerRender(, { withL10n: true }); - await screen.findByText(/Outlet Content/); - }); + it("renders the application content", async () => { + installerRender(, { withL10n: true }); + await screen.findByText(/Outlet Content/); }); }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index dc3b01e317..52cdc7e723 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -31,10 +31,8 @@ import { useL10nConfigChanges } from "~/queries/l10n"; import { useIssuesChanges } from "~/queries/issues"; import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status"; import { useDeprecatedChanges } from "~/queries/storage"; -import { useRootUser } from "~/queries/users"; -import { ROOT, PRODUCT, USER } from "~/routes/paths"; +import { ROOT, PRODUCT } from "~/routes/paths"; import { InstallationPhase } from "~/types/status"; -import { isEmpty } from "~/utils"; /** * Main application component. @@ -45,7 +43,6 @@ function App() { const { connected, error } = useInstallerClientStatus(); const { selectedProduct, products } = useProduct({ suspense: true }); const { language } = useInstallerL10n(); - const { password: isRootPasswordDefined, sshkey: rootSSHKey } = useRootUser(); useL10nConfigChanges(); useProductChanges(); useIssuesChanges(); @@ -77,16 +74,6 @@ function App() { return ; } - if ( - phase === InstallationPhase.Config && - !isBusy && - !isRootPasswordDefined && - isEmpty(rootSSHKey) && - location.pathname !== USER.rootUser.edit - ) { - return ; - } - return ; }; diff --git a/web/src/api/users.ts b/web/src/api/users.ts index 821ca8f4ed..475831f639 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -21,7 +21,7 @@ */ import { del, get, patch, put } from "~/api/http"; -import { FirstUser, RootUser, RootUserChanges } from "~/types/users"; +import { FirstUser, RootUser } from "~/types/users"; /** * Returns the first user's definition @@ -33,7 +33,7 @@ const fetchFirstUser = (): Promise => get("/api/users/first"); * * @param user - Full first user's definition */ -const updateFirstUser = (user: FirstUser) => put("/api/users/first", user); +const updateFirstUser = (user: Partial) => put("/api/users/first", user); /** * Removes the first user definition @@ -50,6 +50,6 @@ const fetchRoot = (): Promise => get("/api/users/root"); * * @param changes - Changes to apply to the root user configuration */ -const updateRoot = (changes: Partial) => patch("/api/users/root", changes); +const updateRoot = (changes: Partial) => patch("/api/users/root", changes); export { fetchFirstUser, updateFirstUser, removeFirstUser, fetchRoot, updateRoot }; diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index ba331df9b4..0ed22c4904 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -66,6 +66,8 @@ --pf-t--global--breakpoint--lg: 64rem; --pf-t--global--font--size--body--sm: var(--pf-t--global--font--size--sm); --pf-t--global--background--color--disabled--default: #dcdbdc; + --pf-t--global--border--radius--pill: var(--pf-t--global--border--radius--small); + --pf-t--global--color--brand--clicked: var(--agm-t--color--pine); } // Temporary CSS rules written during migration to PFv6 @@ -254,6 +256,11 @@ --pf-v6-c-button--BorderRadius: var(--pf-t--global--border--radius--small); } +.pf-v6-c-menu-toggle.pf-m-split-button > :first-child { + text-decoration: none; + color: inherit; +} + // Do not change the default cursor for labels forms because it is confusing // // See: diff --git a/web/src/components/core/InstallButton.test.tsx b/web/src/components/core/InstallButton.test.tsx index bdc3b23388..1f26aa781e 100644 --- a/web/src/components/core/InstallButton.test.tsx +++ b/web/src/components/core/InstallButton.test.tsx @@ -25,7 +25,7 @@ import { screen, waitFor, within } from "@testing-library/react"; import { installerRender, mockRoutes } from "~/test-utils"; import { InstallButton } from "~/components/core"; import { IssuesList } from "~/types/issues"; -import { PRODUCT, ROOT, USER } from "~/routes/paths"; +import { PRODUCT, ROOT } from "~/routes/paths"; const mockStartInstallationFn = jest.fn(); let mockIssuesList: IssuesList; @@ -120,7 +120,6 @@ describe("InstallButton", () => { ["product selection progress", PRODUCT.progress], ["installation progress", ROOT.installationProgress], ["installation finished", ROOT.installationFinished], - ["root authentication", USER.rootUser.edit], ])(`but the installer is rendering the %s screen`, (_, path) => { beforeEach(() => { mockRoutes(path); diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx index b6d2974f70..074d8ba99b 100644 --- a/web/src/components/core/InstallButton.tsx +++ b/web/src/components/core/InstallButton.tsx @@ -21,7 +21,7 @@ */ import React, { useId, useState } from "react"; -import { Button, ButtonProps, Stack, Tooltip } from "@patternfly/react-core"; +import { Button, ButtonProps, Stack, Tooltip, TooltipProps } from "@patternfly/react-core"; import { Popup } from "~/components/core"; import { startInstallation } from "~/api/manager"; import { useAllIssues } from "~/queries/issues"; @@ -98,7 +98,12 @@ const InstallButton = ( const withIssuesText = _("Not possible with the current setup. Click to know more."); const Wrapper = !hasIssues ? React.Fragment : Tooltip; - const tooltipProps = { id: tooltipId, content: withIssuesText }; + const tooltipProps: TooltipProps = { + id: tooltipId, + content: withIssuesText, + position: "left-start", + flipBehavior: ["bottom-end"], + }; return ( <> diff --git a/web/src/components/core/IssuesDrawer.test.tsx b/web/src/components/core/IssuesDrawer.test.tsx index 3e030a3b17..bdd78e0577 100644 --- a/web/src/components/core/IssuesDrawer.test.tsx +++ b/web/src/components/core/IssuesDrawer.test.tsx @@ -112,7 +112,7 @@ describe("IssuesDrawer", () => { const registrationIssues = screen.getByRole("region", { name: "Registration" }); const softwareIssues = screen.getByRole("region", { name: "Software" }); const storageIssues = screen.getByRole("region", { name: "Storage" }); - const usersIssues = screen.getByRole("region", { name: "Users" }); + const usersIssues = screen.getByRole("region", { name: "Authentication" }); const softwareLink = within(softwareIssues).getByRole("link", { name: "Software" }); expect(softwareLink).toHaveAttribute("href", "/software"); @@ -123,7 +123,7 @@ describe("IssuesDrawer", () => { within(storageIssues).getByText("Storage Fake Issue 1"); within(storageIssues).getByText("Storage Fake Issue 2"); - const usersLink = within(usersIssues).getByRole("link", { name: "Users" }); + const usersLink = within(usersIssues).getByRole("link", { name: "Authentication" }); expect(usersLink).toHaveAttribute("href", "/users"); within(usersIssues).getByText("Users Fake Issue"); diff --git a/web/src/components/core/IssuesDrawer.tsx b/web/src/components/core/IssuesDrawer.tsx index fc98301f2b..a92528e3ae 100644 --- a/web/src/components/core/IssuesDrawer.tsx +++ b/web/src/components/core/IssuesDrawer.tsx @@ -46,7 +46,7 @@ const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => { // FIXME: share below headers with navigation menu const scopeHeaders = { - users: _("Users"), + users: _("Authentication"), storage: _("Storage"), software: _("Software"), product: _("Registration"), diff --git a/web/src/components/core/Link.tsx b/web/src/components/core/Link.tsx index dcc570f2cb..c5816fcfa1 100644 --- a/web/src/components/core/Link.tsx +++ b/web/src/components/core/Link.tsx @@ -22,11 +22,11 @@ import React from "react"; import { Button, ButtonProps } from "@patternfly/react-core"; -import { useHref } from "react-router-dom"; +import { To, useHref } from "react-router-dom"; export type LinkProps = Omit & { /** The target route */ - to: string; + to: string | To; /** Whether use PF/Button primary variant */ isPrimary?: boolean; }; diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx index b00a108231..b4373351e8 100644 --- a/web/src/components/core/Page.test.tsx +++ b/web/src/components/core/Page.test.tsx @@ -25,7 +25,7 @@ import { screen, within } from "@testing-library/react"; import { plainRender, mockNavigateFn, mockRoutes, installerRender } from "~/test-utils"; import { Page } from "~/components/core"; import { _ } from "~/i18n"; -import { PRODUCT, ROOT, USER } from "~/routes/paths"; +import { PRODUCT, ROOT } from "~/routes/paths"; let consoleErrorSpy: jest.SpyInstance; @@ -108,7 +108,6 @@ describe("Page", () => { ["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); @@ -122,11 +121,16 @@ describe("Page", () => { }); describe("Page.Cancel", () => { - it("renders a 'Cancel' button that navigates to the top level route by default", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "Cancel" }); - await user.click(button); - expect(mockNavigateFn).toHaveBeenCalledWith(".."); + it("renders a link that navigates to the top level route by default", () => { + plainRender(); + const link = screen.getByRole("link", { name: "Cancel" }); + expect(link).toHaveAttribute("href", ".."); + }); + + it("renders a link that navigates to the given route", () => { + plainRender(); + const link = screen.getByRole("link", { name: "Cancel" }); + expect(link).toHaveAttribute("href", "somewhere"); }); }); diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index f28685afa1..98b1739eb0 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -41,12 +41,13 @@ import { TitleProps, } from "@patternfly/react-core"; import { ProductRegistrationAlert } from "~/components/product"; -import { _ } from "~/i18n"; +import Link, { LinkProps } from "~/components/core/Link"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; -import { To, useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { isEmpty, isObject } from "~/utils"; import { SIDE_PATHS } from "~/routes/paths"; +import { _ } from "~/i18n"; /** * Props accepted by Page.Section @@ -74,7 +75,7 @@ type SectionProps = { type ActionProps = { /** Path to navigate to */ - navigateTo?: To; + navigateTo?: LinkProps["to"]; } & ButtonProps; type SubmitActionProps = { @@ -229,18 +230,17 @@ const Action = ({ navigateTo, children, ...props }: ActionProps) => { */ const Cancel = ({ navigateTo = "..", children, ...props }: ActionProps) => { return ( - + {children || _("Cancel")} - + ); }; /** * Handy component for rendering a "Back" action * - * NOTE: It does not behave like Page.Cancel, since - * * does not support changing the path to navigate to, and - * * always goes one path back in the history (-1) + * NOTE: It does not behave like Page.Cancel, since does not support changing + * the path to navigate to, and always goes one path back in the history (-1) * * NOTE: Not using Page.Cancel for practical reasons about useNavigate * overloading, which kind of forces to write an ugly code for supporting both diff --git a/web/src/components/core/SplitButton.test.tsx b/web/src/components/core/SplitButton.test.tsx new file mode 100644 index 0000000000..af2f3bf0ad --- /dev/null +++ b/web/src/components/core/SplitButton.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, waitForElementToBeRemoved, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import SplitButton from "./SplitButton"; + +const mainOnClickFn = jest.fn(); +const secondaryOnClickFn = jest.fn(); + +const SplitButtonTest = ({ href }: { href?: string }) => ( + + Second test + +); + +describe("SplitButton", () => { + it("renders two buttons if href prop is not provided: the main action button and the toggle button", () => { + plainRender(); + + screen.getByRole("button", { name: "Test" }); + screen.getByRole("button", { name: "More actions" }); + }); + + it("renders the main action as a link and the toggle as a button when the 'href' prop is provided", () => { + plainRender(); + + screen.getByRole("link", { name: "Test" }); + screen.getByRole("button", { name: "More actions" }); + }); + + it("triggers the 'onClick' function when the main action button is clicked", async () => { + const { user } = plainRender(); + + const mainAction = screen.getByRole("button", { name: "Test" }); + await user.click(mainAction); + expect(mainOnClickFn).toHaveBeenCalled(); + }); + + it("allows expanding and collapsing the menu holding additional actions when the toggle button is clicked", async () => { + const { user } = plainRender(); + + const toggleAction = screen.getByRole("button", { name: "More actions" }); + expect(toggleAction).toHaveAttribute("aria-haspopup"); + expect(toggleAction).toHaveAttribute("aria-controls"); + expect(toggleAction).toHaveAttribute("aria-expanded", "false"); + + // Click on the toggle button to open the menu + await user.click(toggleAction); + expect(mainOnClickFn).not.toHaveBeenCalled(); + expect(toggleAction).toHaveAttribute("aria-expanded", "true"); + const moreActions = screen.getByRole("menu"); + + // Click on the toggle button to open the menu + await user.click(toggleAction); + expect(toggleAction).toHaveAttribute("aria-expanded", "false"); + await waitForElementToBeRemoved(moreActions); + }); + + it("closes the menu when a secondary action is clicked", async () => { + const { user } = plainRender(); + + const toggleAction = screen.getByRole("button", { name: "More actions" }); + expect(toggleAction).toHaveAttribute("aria-expanded", "false"); + + // Click on the toggle button to open the menu + await user.click(toggleAction); + + // Find and click on a secondary action inside the expanded menu + const moreActions = screen.getByRole("menu"); + const secondaryAction = within(moreActions).getByRole("menuitem", { name: "Second test" }); + await user.click(secondaryAction); + + // Verify that the secondary action's onClick handler is called + expect(secondaryOnClickFn).toHaveBeenCalled(); + + // Ensure that the menu is closed + expect(toggleAction).toHaveAttribute("aria-expanded", "false"); + await waitForElementToBeRemoved(moreActions); + }); +}); diff --git a/web/src/components/core/SplitButton.tsx b/web/src/components/core/SplitButton.tsx new file mode 100644 index 0000000000..ab7eff880e --- /dev/null +++ b/web/src/components/core/SplitButton.tsx @@ -0,0 +1,127 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useId, useState } from "react"; +import { + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + MenuToggleAction, + MenuToggleActionProps, + MenuToggleProps, +} from "@patternfly/react-core"; +import { Link } from "~/components/core"; +import { _ } from "~/i18n"; + +/** + * Type for props accepted by SplitButton + * + * Children prop is specifically declared below to make it required. + * + * NOTE: Unfortunately, it's not easy to restrict children to a specific + * component type. For more information, see + * https://www.totaltypescript.com/type-safe-children-in-react-and-typescript. + * If necessary, consider using an specific prop instead. + */ +type SplitButtonBaseProps = React.PropsWithChildren<{ + /** Label for the link or button acting as a main action */ + label: React.ReactNode; + /** Accessible label for the toggle button */ + toggleAriaLabel?: MenuToggleProps["aria-label"]; + /** Variant styles of the menu toggle */ + variant?: MenuToggleProps["variant"]; + /** The URL or path for the main action when it should be a link */ + href?: string; + /** Callback to be triggred when main action is clicked */ + onClick?: MenuToggleActionProps["onClick"]; + /** Actions to be placed in the expandable menu */ + children: React.ReactNode; +}>; + +export type SplitButtonProps = + | (SplitButtonBaseProps & { href: string; onClick?: never }) + | (SplitButtonBaseProps & { href?: never; onClick: MenuToggleActionProps["onClick"] }) + | (SplitButtonBaseProps & { href: string; onClick: MenuToggleActionProps["onClick"] }); +/** + * Displays a primary, visible action with a set of related options hidden in an + * expandable menu for the user to choose from. + * + * Built on top of PF/Dropdown, using PF/MenuToggle with a single + * PF/MenuToggleAction for the splitButtonItems prop. + */ +const SplitButton = ({ + href, + label, + onClick, + toggleAriaLabel = _("More actions"), + variant = "primary", + children, +}: SplitButtonProps) => { + const menuId = useId(); + const [isOpen, setIsOpen] = useState(false); + const toggleKey = `${menuId}-toggle`; + const onSelect = () => setIsOpen(false); + const onToggle = () => setIsOpen(!isOpen); + + return ( + setIsOpen(isOpen)} + toggle={(toggleRef) => ( + `) or a button with + * role="link" for actions that navigate to another resource, which would be + * semantically more appropriate. + */ + href ? ( + + {label} + + ) : ( + + {label} + + ), + ]} + isExpanded={isOpen} + aria-haspopup + aria-controls={menuId} + aria-label={toggleAriaLabel} + /> + )} + > + {children} + + ); +}; + +SplitButton.Item = DropdownItem; +export default SplitButton; diff --git a/web/src/components/core/index.ts b/web/src/components/core/index.ts index f1c05854da..604f6e256e 100644 --- a/web/src/components/core/index.ts +++ b/web/src/components/core/index.ts @@ -48,3 +48,4 @@ export { default as SelectWrapper } from "./SelectWrapper"; export { default as NestedContent } from "./NestedContent"; export { default as SubtleContent } from "./SubtleContent"; export { default as MenuHeader } from "./MenuHeader"; +export { default as SplitButton } from "./SplitButton"; diff --git a/web/src/components/product/ProductRegistrationAlert.test.tsx b/web/src/components/product/ProductRegistrationAlert.test.tsx index 3330ffe39b..9ee9c4a4c4 100644 --- a/web/src/components/product/ProductRegistrationAlert.test.tsx +++ b/web/src/components/product/ProductRegistrationAlert.test.tsx @@ -27,7 +27,7 @@ import ProductRegistrationAlert from "./ProductRegistrationAlert"; import { Product } from "~/types/software"; import { useProduct } from "~/queries/software"; import { useIssues } from "~/queries/issues"; -import { PRODUCT, REGISTRATION, ROOT, USER } from "~/routes/paths"; +import { PRODUCT, REGISTRATION, ROOT } from "~/routes/paths"; import { Issue } from "~/types/issues"; const tw: Product = { @@ -75,7 +75,6 @@ const rendersNothingInSomePaths = () => { ["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); diff --git a/web/src/components/storage/BootSelection.test.tsx b/web/src/components/storage/BootSelection.test.tsx index 4347e0212b..fb714948b5 100644 --- a/web/src/components/storage/BootSelection.test.tsx +++ b/web/src/components/storage/BootSelection.test.tsx @@ -194,7 +194,7 @@ describe("BootSelection", () => { it("does not change the boot options on cancel", async () => { const { user } = installerRender(); - const cancel = screen.getByRole("button", { name: "Cancel" }); + const cancel = screen.getByRole("link", { name: "Cancel" }); await user.click(cancel); diff --git a/web/src/components/users/FirstUser.test.tsx b/web/src/components/users/FirstUser.test.tsx new file mode 100644 index 0000000000..2c97368afd --- /dev/null +++ b/web/src/components/users/FirstUser.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import FirstUser from "./FirstUser"; +import { USER } from "~/routes/paths"; + +const mockFirstUser = jest.fn(); +const mockRemoveFirstUserMutation = jest.fn(); + +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/users"), + useFirstUser: () => mockFirstUser(), + useRemoveFirstUserMutation: () => ({ + mutate: mockRemoveFirstUserMutation, + }), +})); + +describe("FirstUser", () => { + describe("when the user is not defined yet", () => { + it("renders a link to define it", () => { + installerRender(); + const createLink = screen.getByRole("link", { name: "Define a user now" }); + expect(createLink).toHaveAttribute("href", USER.firstUser.create); + }); + }); + + describe("when the user is already defined", () => { + beforeEach(() => { + mockFirstUser.mockReturnValue({ fullName: "Gecko Migo", userName: "gmigo" }); + }); + + it("renders the fullname and username", () => { + installerRender(); + screen.getByText("Gecko Migo"); + screen.getByText("gmigo"); + }); + + it("renders a link to edit it", async () => { + installerRender(); + const editLink = screen.getByRole("link", { name: "Edit" }); + expect(editLink).toHaveAttribute("href", USER.firstUser.edit); + }); + + it("renders an action to discard it (within an expandable menu)", async () => { + const { user } = installerRender(); + const moreActionsToggle = screen.getByRole("button", { name: "More actions" }); + await user.click(moreActionsToggle); + const discardAction = screen.getByRole("menuitem", { name: "Discard" }); + await user.click(discardAction); + expect(mockRemoveFirstUserMutation).toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/components/users/FirstUser.tsx b/web/src/components/users/FirstUser.tsx index 18d34f3e89..dc6653054f 100644 --- a/web/src/components/users/FirstUser.tsx +++ b/web/src/components/users/FirstUser.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -20,80 +20,84 @@ * find current contact information at www.suse.com. */ -import React from "react"; -import { Stack } from "@patternfly/react-core"; -import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; -import { useNavigate } from "react-router-dom"; -import { Link, Page, RowActions } from "~/components/core"; -import { _ } from "~/i18n"; +import React, { useId } from "react"; +import { + Card, + CardBody, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, +} from "@patternfly/react-core"; +import { Link, Page, SplitButton } from "~/components/core"; import { useFirstUser, useFirstUserChanges, useRemoveFirstUserMutation } from "~/queries/users"; import { PATHS } from "~/routes/users"; +import { isEmpty } from "~/utils"; +import { _ } from "~/i18n"; -const DefineUserNow = () => ( - - {_("Define a user now")} - -); +const UserActions = () => { + const user = useFirstUser(); + const { mutate: removeUser } = useRemoveFirstUserMutation(); -const UserNotDefined = () => ( - -
{_("No user defined yet.")}
-
- - {_( - "Please, be aware that a user must be defined before installing the system to be able to log into it.", - )} - -
-
-); + if (isEmpty(user?.userName)) { + return ( + + {_("Define a user now")} + + ); + } -const UserData = ({ user, actions }) => { return ( - - - - - - - - - - - - - - -
{_("Full name")}{_("Username")} -
{user.fullName}{user.userName} - -
+ + removeUser()}> + {_("Discard")} + + ); }; -export default function FirstUser() { +const UserData = () => { const user = useFirstUser(); - const removeUser = useRemoveFirstUserMutation(); - const navigate = useNavigate(); + const fullnameTermId = useId(); + const usernameTermId = useId(); - useFirstUserChanges(); + if (isEmpty(user?.userName)) { + return {_("No user defined yet.")}; + } - const isUserDefined = user?.userName && user?.userName !== ""; - const actions = [ - { - title: _("Edit"), - onClick: () => navigate(PATHS.firstUser.edit), - }, - { - title: _("Discard"), - onClick: () => removeUser.mutate(), - isDanger: true, - }, - ]; + return ( + + + + + {_("Full name")} + + {user.fullName} + + + + {_("Username")} + + {user.userName} + + + + + + ); +}; + +export default function FirstUser() { + useFirstUserChanges(); return ( - }> - {isUserDefined ? : } + } + description={_("Define the first user with admin (sudo) privileges for system management.")} + > + ); } diff --git a/web/src/components/users/FirstUserForm.test.tsx b/web/src/components/users/FirstUserForm.test.tsx new file mode 100644 index 0000000000..70c4873c63 --- /dev/null +++ b/web/src/components/users/FirstUserForm.test.tsx @@ -0,0 +1,241 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import FirstUserForm from "./FirstUserForm"; + +let mockFullName: string; +let mockUserName: string; +let mockPassword: string; +let mockHashedPassword: boolean; +const mockFirstUserMutation = jest.fn().mockResolvedValue(true); + +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/users"), + useFirstUser: () => ({ + userName: mockUserName, + fullName: mockFullName, + password: mockPassword, + hashedPassword: mockHashedPassword, + }), + useFirstUserMutation: () => ({ + mutateAsync: mockFirstUserMutation, + }), +})); + +describe("FirstUserForm", () => { + describe("when user is not defined", () => { + beforeEach(() => { + mockUserName = ""; + mockFullName = ""; + mockPassword = ""; + mockHashedPassword = false; + }); + + it("renders the form in 'create' mode", () => { + installerRender(); + + screen.getByRole("heading", { name: "Create user" }); + screen.getByRole("textbox", { name: "Full name" }); + screen.getByRole("textbox", { name: "Username" }); + // NOTE: Password inputs don't have an implicit role, so they must be + // queried using getByLabelText instead. See + // https://github.com/testing-library/dom-testing-library/issues/567 + screen.getByLabelText("Password"); + screen.getByLabelText("Password confirmation"); + screen.getByRole("button", { name: "Accept" }); + screen.getByRole("link", { name: "Cancel" }); + }); + + it("allows defining the user when all data is provided", async () => { + const { user } = installerRender(); + + const fullname = screen.getByRole("textbox", { name: "Full name" }); + const username = screen.getByRole("textbox", { name: "Username" }); + const password = screen.getByLabelText("Password"); + const passwordConfirmation = screen.getByLabelText("Password confirmation"); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.type(fullname, "Gecko Migo"); + await user.type(username, "gmigo"); + await user.type(password, "n0ts3cr3t"); + await user.type(passwordConfirmation, "n0ts3cr3t"); + await user.click(acceptButton); + + expect(mockFirstUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ + fullName: "Gecko Migo", + userName: "gmigo", + password: "n0ts3cr3t", + hashedPassword: false, + }), + ); + }); + + it("warning about missing data", async () => { + const { user } = installerRender(); + + const fullname = screen.getByRole("textbox", { name: "Full name" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.type(fullname, "Gecko Migo"); + await user.click(acceptButton); + + expect(mockFirstUserMutation).not.toHaveBeenCalled(); + screen.getByText("Warning alert:"); + screen.getByText("All fields are required"); + }); + + it("renders errors from the server, if any", async () => { + mockFirstUserMutation.mockRejectedValue({ response: { data: "Username not valid" } }); + const { user } = installerRender(); + + const fullname = screen.getByRole("textbox", { name: "Full name" }); + const username = screen.getByRole("textbox", { name: "Username" }); + const password = screen.getByLabelText("Password"); + const passwordConfirmation = screen.getByLabelText("Password confirmation"); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.type(fullname, "Gecko Migo"); + await user.type(username, "gmigo"); + await user.type(password, "n0ts3cr3t"); + await user.type(passwordConfirmation, "n0ts3cr3t"); + await user.click(acceptButton); + + screen.getByText("Warning alert:"); + screen.getByText("Something went wrong"); + screen.getByText("Username not valid"); + }); + }); + + describe("when user is defined", () => { + beforeEach(() => { + mockFullName = "Gecko Migo"; + mockUserName = "gmigo"; + mockPassword = "n0ts3cr3t"; + mockHashedPassword = false; + }); + + it("renders the form in 'edit' mode", () => { + installerRender(); + + screen.getByRole("heading", { name: "Edit user" }); + const fullNameInput = screen.getByRole("textbox", { name: "Full name" }); + expect(fullNameInput).toHaveValue("Gecko Migo"); + const userNameInput = screen.getByRole("textbox", { name: "Username" }); + expect(userNameInput).toHaveValue("gmigo"); + const passwordInput = screen.queryByLabelText("Password"); + expect(passwordInput).toHaveValue("n0ts3cr3t"); + const passwordConfirmationInput = screen.queryByLabelText("Password confirmation"); + expect(passwordConfirmationInput).toHaveValue("n0ts3cr3t"); + screen.getByRole("button", { name: "Accept" }); + screen.getByRole("link", { name: "Cancel" }); + }); + + it("allows editing user definition without changing the password", async () => { + const { user } = installerRender(); + + const fullname = screen.getByRole("textbox", { name: "Full name" }); + const username = screen.getByRole("textbox", { name: "Username" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.clear(fullname); + await user.type(fullname, "Gecko Loco"); + await user.clear(username); + await user.type(username, "gloco"); + await user.click(acceptButton); + + expect(mockFirstUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ + fullName: "Gecko Loco", + userName: "gloco", + }), + ); + }); + + it("allows editing full user definition", async () => { + const { user } = installerRender(); + + const fullname = screen.getByRole("textbox", { name: "Full name" }); + const username = screen.getByRole("textbox", { name: "Username" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.clear(fullname); + await user.type(fullname, "Gecko Loco"); + await user.clear(username); + await user.type(username, "gloco"); + const password = screen.getByLabelText("Password"); + const passwordConfirmation = screen.getByLabelText("Password confirmation"); + await user.clear(password); + await user.type(password, "m0r3s3cr3t"); + await user.clear(passwordConfirmation); + await user.type(passwordConfirmation, "m0r3s3cr3t"); + await user.click(acceptButton); + + expect(mockFirstUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ + fullName: "Gecko Loco", + userName: "gloco", + password: "m0r3s3cr3t", + hashedPassword: false, + }), + ); + }); + + describe("and a hashed password is set", () => { + beforeEach(() => { + mockPassword = "s3cr3th4$h"; + mockHashedPassword = true; + }); + + it("allows preserving it", async () => { + const { user } = installerRender(); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + screen.getByText("Using a hashed password."); + await user.click(acceptButton); + expect(mockFirstUserMutation).toHaveBeenCalledWith( + expect.not.objectContaining({ hashedPassword: false }), + ); + }); + + it("allows using a plain password instead", async () => { + const { user } = installerRender(); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + screen.getByText("Using a hashed password."); + expect(screen.queryByText(mockPassword)).not.toBeInTheDocument(); + const changeToPlainButton = screen.getByRole("button", { name: "Change" }); + await user.click(changeToPlainButton); + const passwordInput = screen.getByLabelText("Password"); + expect(passwordInput).not.toHaveValue(mockPassword); + const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); + expect(passwordConfirmationInput).not.toHaveValue(mockPassword); + await user.type(passwordInput, "n0tS3cr3t"); + await user.type(passwordConfirmationInput, "n0tS3cr3t"); + await user.click(acceptButton); + expect(mockFirstUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ hashedPassword: false, password: "n0tS3cr3t" }), + ); + }); + }); + }); +}); diff --git a/web/src/components/users/FirstUserForm.tsx b/web/src/components/users/FirstUserForm.tsx index daba21f2bf..550d2da830 100644 --- a/web/src/components/users/FirstUserForm.tsx +++ b/web/src/components/users/FirstUserForm.tsx @@ -23,7 +23,6 @@ import React, { useState, useEffect, useRef } from "react"; import { Alert, - Checkbox, Form, FormGroup, TextInput, @@ -31,19 +30,17 @@ import { MenuContent, MenuList, MenuItem, - Grid, - GridItem, - Stack, - Switch, Content, + ActionGroup, + Button, } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { Loading } from "~/components/layout"; import { PasswordAndConfirmationInput, Page } from "~/components/core"; -import { _ } from "~/i18n"; import { suggestUsernames } from "~/components/users/utils"; import { useFirstUser, useFirstUserMutation } from "~/queries/users"; import { FirstUser } from "~/types/users"; +import { _ } from "~/i18n"; const UsernameSuggestions = ({ isOpen = false, @@ -80,84 +77,74 @@ const UsernameSuggestions = ({ ); }; -type FormState = { - load?: boolean; - user?: FirstUser; - isEditing?: boolean; -}; - // TODO: create an object for errors using the input name as key and show them // close to the related input. // TODO: extract the suggestions logic. export default function FirstUserForm() { const firstUser = useFirstUser(); const setFirstUser = useFirstUserMutation(); - const [state, setState] = useState({}); + const [usingHashedPassword, setUsingHashedPassword] = useState( + firstUser ? firstUser.hashedPassword : false, + ); + const [fullName, setFullName] = useState(firstUser?.fullName); + const [userName, setUserName] = useState(firstUser?.userName); + const [password, setPassword] = useState(usingHashedPassword ? "" : firstUser?.password); const [errors, setErrors] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const [insideDropDown, setInsideDropDown] = useState(false); const [focusedIndex, setFocusedIndex] = useState(-1); const [suggestions, setSuggestions] = useState([]); - const [changePassword, setChangePassword] = useState(true); + // FIXME + const [changePassword] = useState(firstUser?.userName === ""); const usernameInputRef = useRef(); const navigate = useNavigate(); const passwordRef = useRef(); - useEffect(() => { - const editing = firstUser.userName !== ""; - setState({ - load: true, - user: firstUser, - isEditing: editing, - }); - setChangePassword(!editing); - }, [firstUser]); - useEffect(() => { if (showSuggestions) { setFocusedIndex(-1); } }, [showSuggestions]); - if (!state.load) return ; + if (!firstUser) return ; + + const isEditing = firstUser.userName !== ""; const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setErrors([]); - + const nextErrors = []; const passwordInput = passwordRef.current; - const formData = new FormData(e.currentTarget); - const user: Partial & { passwordConfirmation?: string } = {}; - // FIXME: have a look to https://www.patternfly.org/components/forms/form#form-state - formData.forEach((value, key) => { - user[key] = value; - }); - if (!changePassword) { - delete user.password; - } else { - // the web UI only supports plain text passwords, this resets the flag if a hashed - // password was previously set from CLI - user.hashedPassword = false; + const data: Partial = { + fullName, + userName, + password: usingHashedPassword ? firstUser.password : password, + hashedPassword: usingHashedPassword, + }; + + const requiredData = { ...data }; + + if (!changePassword) delete requiredData.password; + + if (Object.values(requiredData).some((v) => v === "")) { + nextErrors.push(_("All fields are required")); } - delete user.passwordConfirmation; - user.autologin = !!user.autologin; - if (!passwordInput?.validity.valid) { - setErrors([passwordInput?.validationMessage]); - return; + if (changePassword && !usingHashedPassword) { + data.hashedPassword = false; + !passwordInput?.validity.valid && nextErrors.push(passwordInput?.validationMessage); } - // FIXME: improve validations - if (Object.values(user).some((v) => v === "")) { - setErrors([_("All fields are required")]); + if (nextErrors.length > 0) { + setErrors(nextErrors); return; } setFirstUser - .mutateAsync({ ...state.user, ...user }) - .catch((e) => setErrors(e)) - .then(() => navigate("..")); + .mutateAsync({ ...data }) + .then(() => navigate("..")) + .catch((e) => setErrors([e.response.data])); }; const onSuggestionSelected = (suggestion: string) => { @@ -207,11 +194,11 @@ export default function FirstUserForm() { return ( - {state.isEditing ? _("Edit user") : _("Create user")} + {isEditing ? _("Edit user") : _("Create user")} -
+ {errors.length > 0 && ( {errors.map((e, i) => ( @@ -219,87 +206,57 @@ export default function FirstUserForm() { ))} )} - - - - - - setSuggestions(suggestUsernames(e.target.value))} - /> - - - - !insideDropDown && setShowSuggestions(false)} - /> - - - - - - - - - {state.isEditing && ( - setChangePassword(!changePassword)} - /> - )} - - - - - - - - - - + + setFullName(value)} + onBlur={(e) => setSuggestions(suggestUsernames(e.target.value))} + /> + + + setUserName(value)} + onFocus={renderSuggestions} + onKeyDown={handleKeyDown} + onBlur={() => !insideDropDown && setShowSuggestions(false)} + /> + + + {usingHashedPassword && ( + + {_("Using a hashed password.")}{" "} + + + )} + {!usingHashedPassword && ( + setPassword(value)} + /> + )} + + + +
- - - - -
); } diff --git a/web/src/components/users/RootAuthMethods.test.tsx b/web/src/components/users/RootAuthMethods.test.tsx deleted file mode 100644 index 7baa7c11f3..0000000000 --- a/web/src/components/users/RootAuthMethods.test.tsx +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright (c) [2023-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { RootAuthMethods } from "~/components/users"; - -const mockRootUserMutation = { mutate: jest.fn(), mutateAsync: jest.fn() }; -let mockPassword: boolean; -let mockSSHKey: string; - -jest.mock("~/queries/users", () => ({ - ...jest.requireActual("~/queries/users"), - useRootUser: () => ({ password: mockPassword, sshkey: mockSSHKey }), - useRootUserMutation: () => mockRootUserMutation, - useRootUserChanges: () => jest.fn(), -})); - -const testKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example"; - -beforeEach(() => { - mockPassword = false; - mockSSHKey = ""; -}); - -describe("when no method is defined", () => { - it("renders a text inviting the user to define at least one", () => { - plainRender(); - - screen.getByText("No root authentication method defined yet."); - screen.getByText(/at least one/); - }); -}); - -describe("and the password has been set", () => { - beforeEach(() => { - mockPassword = true; - }); - - it("renders the 'Already set' status", async () => { - plainRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - within(passwordRow).getByText("Already set"); - }); - - it("does not renders the 'Set' action", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const setAction = within(menu).queryByRole("menuitem", { name: "Set" }); - expect(setAction).toBeNull(); - }); - - it("allows the user to change the already set password", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const changeAction = within(menu).queryByRole("menuitem", { name: "Change" }); - await user.click(changeAction); - screen.getByRole("dialog", { name: "Change the root password" }); - }); - - it("allows the user to discard the chosen password", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const discardAction = within(menu).queryByRole("menuitem", { name: "Discard" }); - await user.click(discardAction); - - expect(mockRootUserMutation.mutate).toHaveBeenCalledWith({ password: "" }); - }); -}); - -describe("the password is not set yet", () => { - // Mock another auth method for reaching the table - beforeEach(() => { - mockSSHKey = "Fake"; - }); - - it("renders the 'Not set' status", async () => { - plainRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - within(passwordRow).getByText("Not set"); - }); - - it("allows the user to set a password", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const setAction = within(menu).getByRole("menuitem", { name: "Set" }); - await user.click(setAction); - screen.getByRole("dialog", { name: "Set a root password" }); - }); - - it("does not render the 'Change' nor the 'Discard' actions", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const changeAction = within(menu).queryByRole("menuitem", { name: "Change" }); - const discardAction = within(menu).queryByRole("menuitem", { name: "Discard" }); - - expect(changeAction).toBeNull(); - expect(discardAction).toBeNull(); - }); -}); - -describe("and the SSH Key has been set", () => { - beforeEach(() => { - mockSSHKey = testKey; - }); - - it("renders its truncated content keeping the comment visible when possible", async () => { - plainRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - within(sshKeyRow).getByText("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+"); - within(sshKeyRow).getByText("test@example"); - }); - - it("does not renders the 'Set' action", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const setAction = within(menu).queryByRole("menuitem", { name: "Set" }); - expect(setAction).toBeNull(); - }); - - it("allows the user to change it", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const changeAction = within(menu).queryByRole("menuitem", { name: "Change" }); - await user.click(changeAction); - screen.getByRole("dialog", { name: "Edit the SSH Public Key for root" }); - }); - - it("allows the user to discard it", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const discardAction = within(menu).queryByRole("menuitem", { name: "Discard" }); - await user.click(discardAction); - - expect(mockRootUserMutation.mutate).toHaveBeenCalledWith({ sshkey: "" }); - }); -}); - -describe("but the SSH Key is not set yet", () => { - // Mock another auth method for reaching the table - beforeEach(() => { - mockPassword = true; - }); - - it("renders the 'Not set' status", async () => { - plainRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - within(sshKeyRow).getByText("Not set"); - }); - - it("allows the user to set a key", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const setAction = within(menu).getByRole("menuitem", { name: "Set" }); - await user.click(setAction); - screen.getByRole("dialog", { name: "Add a SSH Public Key for root" }); - }); - - it("does not render the 'Change' nor the 'Discard' actions", async () => { - const { user } = plainRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const menu = screen.getByRole("menu"); - const changeAction = within(menu).queryByRole("menuitem", { name: "Change" }); - const discardAction = within(menu).queryByRole("menuitem", { name: "Discard" }); - - expect(changeAction).toBeNull(); - expect(discardAction).toBeNull(); - }); -}); diff --git a/web/src/components/users/RootAuthMethods.tsx b/web/src/components/users/RootAuthMethods.tsx deleted file mode 100644 index 65ecc030e1..0000000000 --- a/web/src/components/users/RootAuthMethods.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/* - * 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 { Button, Stack, Truncate } from "@patternfly/react-core"; -import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; -import { Page, RowActions } from "~/components/core"; -import { RootPasswordPopup, RootSSHKeyPopup } from "~/components/users"; - -import { _ } from "~/i18n"; -import { useRootUser, useRootUserChanges, useRootUserMutation } from "~/queries/users"; - -const NoMethodDefined = () => ( - -
{_("No root authentication method defined yet.")}
-
- - {_( - "Please, define at least one authentication method for logging into the system as root.", - )} - -
-
-); - -const SSHKeyLabel = ({ sshKey }) => { - const trailingChars = Math.min(sshKey.length - sshKey.lastIndexOf(" "), 30); - - return ; -}; - -const Content = ({ - isPasswordDefined, - isSSHKeyDefined, - sshKey, - passwordActions, - sshKeyActions, -}) => { - if (!isPasswordDefined && !isSSHKeyDefined) return ; - - return ( - - - - {/* TRANSLATORS: table header, user authentication method */} - - {/* TRANSLATORS: table header */} - - - - - - - - - - - - - - - -
{_("Method")}{_("Status")} -
{_("Password")}{isPasswordDefined ? _("Already set") : _("Not set")} - -
{_("SSH Key")} - {isSSHKeyDefined ? : _("Not set")} - - -
- ); -}; - -export default function RootAuthMethods() { - const setRootUser = useRootUserMutation(); - const [isSSHKeyFormOpen, setIsSSHKeyFormOpen] = useState(false); - const [isPasswordFormOpen, setIsPasswordFormOpen] = useState(false); - - const { password: isPasswordDefined, sshkey: sshKey } = useRootUser(); - - useRootUserChanges(); - - const isSSHKeyDefined = sshKey !== ""; - const openPasswordForm = () => setIsPasswordFormOpen(true); - const openSSHKeyForm = () => setIsSSHKeyFormOpen(true); - const closePasswordForm = () => setIsPasswordFormOpen(false); - const closeSSHKeyForm = () => setIsSSHKeyFormOpen(false); - - const passwordActions = [ - { - title: isPasswordDefined ? _("Change") : _("Set"), - onClick: openPasswordForm, - }, - isPasswordDefined && { - title: _("Discard"), - onClick: () => setRootUser.mutate({ password: "" }), - isDanger: true, - }, - ].filter(Boolean); - - const sshKeyActions = [ - { - title: isSSHKeyDefined ? _("Change") : _("Set"), - onClick: openSSHKeyForm, - }, - sshKey && { - title: _("Discard"), - onClick: () => setRootUser.mutate({ sshkey: "" }), - isDanger: true, - }, - ].filter(Boolean); - - return ( - - {/* TRANSLATORS: push button label */} - - {/* TRANSLATORS: push button label */} - - - ) - } - > - - - {isPasswordFormOpen && ( - - )} - - {isSSHKeyFormOpen && ( - - )} - - ); -} diff --git a/web/src/components/users/RootAuthMethodsPage.test.tsx b/web/src/components/users/RootAuthMethodsPage.test.tsx deleted file mode 100644 index 153dc8b574..0000000000 --- a/web/src/components/users/RootAuthMethodsPage.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 { mockNavigateFn, installerRender } from "~/test-utils"; -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, -})); - -describe("RootAuthMethodsPage", () => { - it("allows setting a root password", async () => { - const { user } = installerRender(); - const passwordInput = screen.getByLabelText("Password for root user"); - const acceptButton = screen.getByRole("button", { name: "Accept" }); - - // The Accept button must be enable only when password has some value - expect(acceptButton).toHaveAttribute("disabled"); - - await user.type(passwordInput, "s3cr3t"); - expect(acceptButton).not.toHaveAttribute("disabled"); - - await user.clear(passwordInput); - expect(acceptButton).toHaveAttribute("disabled"); - - await user.type(passwordInput, "t0ps3cr3t"); - - // Request setting root password when Accept button is clicked - await user.click(acceptButton); - expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ - password: "t0ps3cr3t", - hashedPassword: false, - }); - - // After submitting the data, it must navigate - expect(mockNavigateFn).toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/users/RootAuthMethodsPage.tsx b/web/src/components/users/RootAuthMethodsPage.tsx deleted file mode 100644 index cca5fe4943..0000000000 --- a/web/src/components/users/RootAuthMethodsPage.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (c) [2024-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useRef, useState } from "react"; -import { Bullseye, Flex, Form, FormGroup } from "@patternfly/react-core"; -import { useLocation, useNavigate } from "react-router-dom"; -import { Page, PasswordInput } from "~/components/core"; -import { useRootUserMutation } from "~/queries/users"; -import { ROOT as PATHS } from "~/routes/paths"; -import { isEmpty } from "~/utils"; -import { _ } from "~/i18n"; -import shadowUtils from "@patternfly/react-styles/css/utilities/BoxShadow/box-shadow"; - -/** - * A page component for setting at least one root authentication method - * - * NOTE: This page will be automatically displayed only when no root authentication - * method is set. It is not within the scope of this component to fill data if - * users manually enter the route path. - */ -function RootAuthMethodsPage() { - const passwordRef = useRef(); - const navigate = useNavigate(); - const location = useLocation(); - const setRootUser = useRootUserMutation(); - const [password, setPassword] = useState(""); - - const isFormValid = !isEmpty(password); - - const accept = async (e: React.SyntheticEvent) => { - e.preventDefault(); - if (isEmpty(password)) return; - - await setRootUser.mutateAsync({ password, hashedPassword: false }); - - navigate(location.state?.from || PATHS.root, { replace: true }); - }; - - return ( - - - - -

{_("Provide a password to ensure administrative access to the system.")}

-

- {_( - "You can change it or select another authentication method in the 'Users' section before installing.", - )} -

- - } - pfCardProps={{ - isCompact: false, - isFullHeight: false, - className: shadowUtils.boxShadowMd, - }} - pfCardBodyProps={{ isFilled: true }} - actions={ - - } - > -
- - setPassword(value)} - autoFocus - /> - -
-
-
-
-
- ); -} - -export default RootAuthMethodsPage; diff --git a/web/src/components/users/RootPasswordPopup.jsx b/web/src/components/users/RootPasswordPopup.jsx deleted file mode 100644 index 547b48a9a4..0000000000 --- a/web/src/components/users/RootPasswordPopup.jsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) [2022-2023] 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. - */ - -// @ts-check - -import React, { useState, useRef } from "react"; -import { Form } from "@patternfly/react-core"; -import { PasswordAndConfirmationInput, Popup } from "~/components/core"; - -import { _ } from "~/i18n"; -import { useRootUserMutation } from "~/queries/users"; - -/** - * A dialog holding the form to change the root password - * @component - * - * @example Simple usage - * onCloseCallback()} /> - * - * @param {object} props - * @param {string} [props.title="Root password"] - the text to be used as the title of the dialog - * @param {boolean} props.isOpen - whether the dialog should be visible - * @param {function} props.onClose - the function to be called when the dialog is closed - */ -export default function RootPasswordPopup({ title = _("Root password"), isOpen, onClose }) { - const setRootUser = useRootUserMutation(); - const [password, setPassword] = useState(""); - const [isValidPassword, setIsValidPassword] = useState(true); - const passwordRef = useRef(); - - const close = () => { - setPassword(""); - onClose(); - }; - - const accept = async (e) => { - e.preventDefault(); - // TODO: handle errors - // the web UI only supports plain text passwords, this resets the flag if a hashed password - // was previously set from CLI - if (password !== "") await setRootUser.mutateAsync({ password, hashedPassword: false }); - close(); - }; - - const onPasswordChange = (_, value) => setPassword(value); - - const onPasswordValidation = (isValid) => setIsValidPassword(isValid); - - return ( - -
- - - - - - - -
- ); -} diff --git a/web/src/components/users/RootPasswordPopup.test.jsx b/web/src/components/users/RootPasswordPopup.test.jsx deleted file mode 100644 index db8725256e..0000000000 --- a/web/src/components/users/RootPasswordPopup.test.jsx +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) [2022-2023] 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, waitFor, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { RootPasswordPopup } from "~/components/users"; - -const mockRootUserMutation = { mutateAsync: jest.fn() }; -let mockPassword; - -jest.mock("~/queries/users", () => ({ - ...jest.requireActual("~/queries/users"), - useRootUser: () => ({ password: mockPassword, sshkey: "" }), - useRootUserMutation: () => mockRootUserMutation, - useRootUserChanges: () => jest.fn(), -})); - -const onCloseCallback = jest.fn(); -const password = "nots3cr3t"; - -describe("when it is closed", () => { - it("renders nothing", async () => { - const { container } = plainRender(); - await waitFor(() => expect(container).toBeEmptyDOMElement()); - }); -}); - -describe("when it is open", () => { - it("renders default title when none if given", () => { - plainRender(); - const dialog = screen.queryByRole("dialog"); - within(dialog).getByText("Root password"); - }); - - it("renders the given title", () => { - plainRender(); - const dialog = screen.getByRole("dialog"); - within(dialog).getByText("Change The Root Password"); - }); - - it("allows changing the password", async () => { - const { user } = plainRender(); - - await screen.findByRole("dialog"); - - const passwordInput = await screen.findByLabelText("Password"); - const passwordConfirmationInput = await screen.findByLabelText("Password confirmation"); - const confirmButton = await screen.findByRole("button", { name: /Confirm/i }); - - expect(confirmButton).toBeDisabled(); - await user.type(passwordInput, password); - expect(confirmButton).toBeDisabled(); - await user.type(passwordConfirmationInput, password); - expect(confirmButton).toBeEnabled(); - await user.click(confirmButton); - - expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ - password, - hashedPassword: false, - }); - expect(onCloseCallback).toHaveBeenCalled(); - }); - - it("allows dismissing the dialog without changing the password", async () => { - const { user } = plainRender(); - await screen.findByRole("dialog"); - const cancelButton = await screen.findByRole("button", { name: /Cancel/i }); - await user.click(cancelButton); - - expect(mockRootUserMutation.mutateAsync).not.toHaveBeenCalled(); - expect(onCloseCallback).toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/users/RootSSHKeyPopup.jsx b/web/src/components/users/RootSSHKeyPopup.jsx deleted file mode 100644 index d324b32197..0000000000 --- a/web/src/components/users/RootSSHKeyPopup.jsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) [2022-2023] 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 { Form, FormGroup, FileUpload } from "@patternfly/react-core"; - -import { _ } from "~/i18n"; -import { Popup } from "~/components/core"; -import { useRootUserMutation } from "~/queries/users"; - -/** - * A dialog holding the form to set the SSH Public key for root - * @component - * - * @example Simple usage - * onCloseCallback()} /> - * - * @param {object} props - * @param {string} [props.title="Set root SSH public key"] - the text to be used as the title of the dialog - * @param {string} [props.currentKey=""] - the current SSH Key, if any - * @param {boolean} props.isOpen - whether the dialog should be visible - * @param {function} props.onClose - the function to be called when the dialog is closed - */ -export default function RootSSHKeyPopup({ - title = _("Set root SSH public key"), - currentKey = "", - isOpen, - onClose, -}) { - const setRootUser = useRootUserMutation(); - const [isLoading, setIsLoading] = useState(false); - const [sshKey, setSSHKey] = useState(currentKey); - - const startUploading = () => setIsLoading(true); - const stopUploading = () => setIsLoading(false); - const clearKey = () => setSSHKey(""); - - const close = () => { - clearKey(); - onClose(); - }; - - const accept = async (e) => { - e.preventDefault(); - await setRootUser.mutateAsync({ sshkey: sshKey }); - // TODO: handle/display errors - close(); - }; - - return ( - -
- - setSSHKey(value)} - onTextChange={(_, value) => setSSHKey(value)} - onReadStarted={startUploading} - onReadFinished={stopUploading} - onClearClick={clearKey} - /> - -
- - - - - -
- ); -} diff --git a/web/src/components/users/RootSSHKeyPopup.test.jsx b/web/src/components/users/RootSSHKeyPopup.test.jsx deleted file mode 100644 index 2d4223d200..0000000000 --- a/web/src/components/users/RootSSHKeyPopup.test.jsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) [2022-2023] 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, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { RootSSHKeyPopup } from "~/components/users"; - -const mockRootUserMutation = { mutateAsync: jest.fn() }; -let mockSSHKey; - -jest.mock("~/queries/users", () => ({ - ...jest.requireActual("~/queries/users"), - useRootUser: () => ({ sshkey: mockSSHKey }), - useRootUserMutation: () => mockRootUserMutation, - useRootUserChanges: () => jest.fn(), -})); - -const onCloseCallback = jest.fn(); -const testKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example"; - -describe("when it is closed", () => { - it("renders nothing", () => { - const { container } = plainRender(); - expect(container).toBeEmptyDOMElement(); - }); -}); - -describe("when it is open", () => { - it("renders default title when none if given", () => { - plainRender(); - const dialog = screen.getByRole("dialog"); - within(dialog).getByText("Set root SSH public key"); - }); - - it("renders the given title", () => { - plainRender(); - const dialog = screen.getByRole("dialog"); - within(dialog).getByText("Root SSHKey"); - }); - - it("contains the given key, if any", () => { - plainRender(); - const dialog = screen.getByRole("dialog"); - within(dialog).getByText(testKey); - }); - - it("allows defining a new root SSH public key", async () => { - const { user } = plainRender(); - - const dialog = await screen.findByRole("dialog"); - const sshKeyInput = within(dialog).getByLabelText("Root SSH public key"); - const confirmButton = within(dialog).getByRole("button", { name: /Confirm/i }); - - expect(confirmButton).toBeDisabled(); - await user.type(sshKeyInput, testKey); - expect(confirmButton).toBeEnabled(); - await user.click(confirmButton); - - expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ sshkey: testKey }); - expect(onCloseCallback).toHaveBeenCalled(); - }); - - it("does not change anything if the user cancels", async () => { - const { user } = plainRender(); - const dialog = await screen.findByRole("dialog"); - const sshKeyInput = within(dialog).getByLabelText("Root SSH public key"); - const cancelButton = within(dialog).getByRole("button", { name: /Cancel/i }); - - await user.type(sshKeyInput, testKey); - await user.click(cancelButton); - - expect(mockRootUserMutation.mutateAsync).not.toHaveBeenCalled(); - expect(onCloseCallback).toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/users/RootUser.test.tsx b/web/src/components/users/RootUser.test.tsx new file mode 100644 index 0000000000..7108765397 --- /dev/null +++ b/web/src/components/users/RootUser.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright (c) [2023-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { RootUser } from "~/components/users"; +import { USER } from "~/routes/paths"; + +let mockPassword: string; +let mockPublicKey: string; + +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/users"), + useRootUser: () => ({ password: mockPassword, sshPublicKey: mockPublicKey }), + useRootUserChanges: () => jest.fn(), +})); + +const testKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example"; + +beforeEach(() => { + mockPassword = ""; + mockPublicKey = ""; +}); + +describe("RootUser", () => { + it("renders an edit action", () => { + plainRender(); + + const editLink = screen.getByRole("link", { name: "Edit" }); + expect(editLink).toHaveAttribute("href", USER.rootUser.edit); + }); + + describe("if no method is defined", () => { + it("renders them as 'Not defined'", () => { + plainRender(); + + expect(screen.getAllByText("Not defined").length).toEqual(2); + }); + }); + + describe("when password has been defined", () => { + beforeEach(() => { + mockPassword = "n0tS3cr3t"; + }); + + it("renders the 'Defined (hidden)' text", async () => { + plainRender(); + + screen.getByText("Defined (hidden)"); + }); + }); + + describe("when SSH Key has been defined", () => { + beforeEach(() => { + mockPublicKey = testKey; + }); + + it("renders its truncated content keeping the comment visible when possible", async () => { + plainRender(); + + screen.getByText("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+"); + screen.getByText("test@example"); + }); + }); +}); diff --git a/web/src/components/users/RootUser.tsx b/web/src/components/users/RootUser.tsx new file mode 100644 index 0000000000..861e1c15ee --- /dev/null +++ b/web/src/components/users/RootUser.tsx @@ -0,0 +1,77 @@ +/* + * Copyright (c) [2023-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { + Card, + CardBody, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Truncate, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { useRootUser, useRootUserChanges } from "~/queries/users"; +import { USER } from "~/routes/paths"; +import { isEmpty } from "~/utils"; +import { _ } from "~/i18n"; + +const SSHKeyLabel = ({ sshKey }) => { + const trailingChars = Math.min(sshKey.length - sshKey.lastIndexOf(" "), 30); + + return ; +}; + +export default function RootUser() { + const { password, sshPublicKey } = useRootUser(); + useRootUserChanges(); + + return ( + {_("Edit")}} + > + + + + + {_("Password")} + + {password ? _("Defined (hidden)") : _("Not defined")} + + + + {_("Public SSH Key")} + + {isEmpty(sshPublicKey) ? _("Not defined") : } + + + + + + + ); +} diff --git a/web/src/components/users/RootUserForm.test.tsx b/web/src/components/users/RootUserForm.test.tsx new file mode 100644 index 0000000000..184fb36229 --- /dev/null +++ b/web/src/components/users/RootUserForm.test.tsx @@ -0,0 +1,199 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import RootUserForm from "./RootUserForm"; + +let mockPassword: string; +let mockPublicKey: string; +let mockHashedPassword: boolean; +const mockRootUserMutation = jest.fn().mockResolvedValue(true); + +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/users"), + useRootUser: () => ({ + password: mockPassword, + sshPublicKey: mockPublicKey, + hashedPassword: mockHashedPassword, + }), + useRootUserMutation: () => ({ + mutateAsync: mockRootUserMutation, + }), +})); + +describe("RootUserForm", () => { + beforeEach(() => { + mockPassword = "n0ts3cr3t"; + mockHashedPassword = false; + mockPublicKey = ""; + }); + + it("allows setting/editing a password", async () => { + const { user } = installerRender(); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + const passwordInput = screen.getByLabelText("Password"); + const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); + await user.clear(passwordInput); + await user.type(passwordInput, "m0r3S3cr3t"); + await user.clear(passwordConfirmationInput); + await user.type(passwordConfirmationInput, "m0r3S3cr3t"); + await user.click(acceptButton); + expect(mockRootUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ password: "m0r3S3cr3t", hashedPassword: false }), + ); + }); + + it("does not allow setting an empty password", async () => { + const { user } = installerRender(); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + const passwordInput = screen.getByLabelText("Password"); + const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); + await user.clear(passwordInput); + await user.clear(passwordConfirmationInput); + expect(passwordInput).toHaveValue(""); + expect(passwordConfirmationInput).toHaveValue(""); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText("Password is empty."); + expect(mockRootUserMutation).not.toHaveBeenCalled(); + }); + + it("renders password validation errors, if any", async () => { + const { user } = installerRender(); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + const passwordInput = screen.getByLabelText("Password"); + const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); + await user.type(passwordInput, "n0tS3cr3t"); + await user.type(passwordConfirmationInput, "S3cr3t"); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText("Passwords do not match"); + expect(mockRootUserMutation).not.toHaveBeenCalled(); + }); + + it("allows clearing the password", async () => { + const { user } = installerRender(); + const passwordToggle = screen.getByRole("switch", { name: "Use password" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + expect(passwordToggle).toBeChecked(); + await user.click(passwordToggle); + expect(passwordToggle).not.toBeChecked(); + await user.click(acceptButton); + expect(mockRootUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ password: "", hashedPassword: false }), + ); + }); + + it("allows setting a public SSH Key ", async () => { + const { user } = installerRender(); + const sshPublicKeyToggle = screen.getByRole("switch", { name: "Use public SSH Key" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(sshPublicKeyToggle); + const sshPublicKeyInput = screen.getByRole("textbox", { name: "File upload" }); + await user.type(sshPublicKeyInput, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example"); + await user.click(acceptButton); + expect(mockRootUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ + sshPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example", + }), + ); + }); + + it("does not allow setting an empty public SSH Key", async () => { + const { user } = installerRender(); + const sshPublicKeyToggle = screen.getByRole("switch", { name: "Use public SSH Key" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(sshPublicKeyToggle); + expect(sshPublicKeyToggle).toBeChecked(); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText("Public SSH Key is empty."); + expect(mockRootUserMutation).not.toHaveBeenCalled(); + }); + + it("allows clearing the public SSH Key", async () => { + mockPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example"; + const { user } = installerRender(); + const sshPublicKeyToggle = screen.getByRole("switch", { name: "Use public SSH Key" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + expect(sshPublicKeyToggle).toBeChecked(); + await user.click(sshPublicKeyToggle); + expect(sshPublicKeyToggle).not.toBeChecked(); + await user.click(acceptButton); + expect(mockRootUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ sshPublicKey: "" }), + ); + }); + + describe("when a hashed password is set", () => { + beforeEach(() => { + mockPassword = "h4$hPwd"; + mockHashedPassword = true; + }); + + it("allows preserving it", async () => { + const { user } = installerRender(); + const passwordToggle = screen.getByRole("switch", { name: "Use password" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + expect(passwordToggle).toBeChecked(); + screen.getByText("Using a hashed password."); + await user.click(acceptButton); + expect(mockRootUserMutation).toHaveBeenCalledWith( + expect.not.objectContaining({ hashedPassword: false }), + ); + }); + + it("allows discarding it", async () => { + const { user } = installerRender(); + const passwordToggle = screen.getByRole("switch", { name: "Use password" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + expect(passwordToggle).toBeChecked(); + await user.click(passwordToggle); + expect(passwordToggle).not.toBeChecked(); + await user.click(acceptButton); + expect(mockRootUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ hashedPassword: false, password: "" }), + ); + }); + + it("allows using a plain password instead", async () => { + const { user } = installerRender(); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + const changeToPlainButton = screen.getByRole("button", { name: "Change" }); + await user.click(changeToPlainButton); + const passwordInput = screen.getByLabelText("Password"); + const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); + await user.type(passwordInput, "n0tS3cr3t"); + await user.type(passwordConfirmationInput, "n0tS3cr3t"); + await user.click(acceptButton); + expect(mockRootUserMutation).toHaveBeenCalledWith( + expect.objectContaining({ hashedPassword: false, password: "n0tS3cr3t" }), + ); + }); + }); +}); diff --git a/web/src/components/users/RootUserForm.tsx b/web/src/components/users/RootUserForm.tsx new file mode 100644 index 0000000000..9165ecffa9 --- /dev/null +++ b/web/src/components/users/RootUserForm.tsx @@ -0,0 +1,211 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useRef, useState } from "react"; +import { + ActionGroup, + Alert, + Button, + Card, + CardBody, + Content, + FileUpload, + Form, + FormGroup, + Switch, +} from "@patternfly/react-core"; +import { useNavigate } from "react-router-dom"; +import { Page, PasswordAndConfirmationInput } from "~/components/core"; +import { useRootUser, useRootUserMutation } from "~/queries/users"; +import { RootUser } from "~/types/users"; +import { isEmpty } from "~/utils"; +import { _ } from "~/i18n"; + +const AVAILABLE_METHODS = ["password", "sshPublicKey"] as const; +type ActiveMethods = { [key in (typeof AVAILABLE_METHODS)[number]]?: boolean }; + +const initialState = (user: RootUser): ActiveMethods => + AVAILABLE_METHODS.reduce((result, key) => { + return { ...result, [key]: !isEmpty(user[key]) }; + }, {}); + +const SSHKeyField = ({ value, onChange }) => { + const [isUploading, setIsUploading] = useState(false); + + const startUploading = () => setIsUploading(true); + const stopUploading = () => setIsUploading(false); + const clearKey = () => onChange(""); + + return ( + onChange(value)} + onTextChange={(_, value) => onChange(value)} + onReadStarted={startUploading} + onReadFinished={stopUploading} + onClearClick={clearKey} + /> + ); +}; + +const RootUserForm = () => { + const navigate = useNavigate(); + const rootUser = useRootUser(); + const { mutateAsync: updateRootUser } = useRootUserMutation(); + const [activeMethods, setActiveMethods] = useState(initialState(rootUser)); + const [errors, setErrors] = useState([]); + const [usingHashedPassword, setUsingHashedPassword] = useState( + rootUser ? rootUser.hashedPassword : false, + ); + const [password, setPassword] = useState(usingHashedPassword ? "" : rootUser?.password); + const [sshkey, setSshKey] = useState(rootUser?.sshPublicKey); + const passwordRef = useRef(); + + const onPasswordChange = (_, value: string) => setPassword(value); + const toggleMethod = (method: keyof ActiveMethods) => { + const nextMethodsState = { ...activeMethods, [method]: !activeMethods[method] }; + setActiveMethods(nextMethodsState); + }; + + const onSubmit = (e) => { + e.preventDefault(); + const nextErrors = []; + setErrors([]); + + const passwordInput = passwordRef.current; + + if (activeMethods.password && !usingHashedPassword) { + isEmpty(password) && nextErrors.push(_("Password is empty.")); + !passwordInput?.validity.valid && nextErrors.push(passwordInput?.validationMessage); + } + + if (activeMethods.sshPublicKey && isEmpty(sshkey)) { + nextErrors.push(_("Public SSH Key is empty.")); + } + + if (nextErrors.length > 0) { + setErrors(nextErrors); + return; + } + + const data: Partial = { + sshPublicKey: activeMethods.sshPublicKey ? sshkey : "", + }; + + if (!activeMethods.password) { + data.password = ""; + data.hashedPassword = false; + } + + if (activeMethods.password) { + data.password = usingHashedPassword ? rootUser.password : password; + data.hashedPassword = usingHashedPassword; + } + + updateRootUser(data) + .then(() => navigate("..")) + .catch((e) => setErrors([e.response.data])); + }; + return ( + + + {_("Root authentication methods")} + + + +
+ {errors.length > 0 && ( + + {errors.map((e, i) => ( +

{e}

+ ))} +
+ )} + toggleMethod("password")} + /> + } + > + {activeMethods.password && ( + + + {usingHashedPassword ? ( + + {_("Using a hashed password.")}{" "} + + + ) : ( + + )} + + + )} + + + toggleMethod("sshPublicKey")} + /> + } + > + {activeMethods.sshPublicKey && ( + + + + + + )} + + + + + + +
+
+
+ ); +}; + +export default RootUserForm; diff --git a/web/src/components/users/UsersPage.tsx b/web/src/components/users/UsersPage.tsx index 2d6afec628..301f67d50d 100644 --- a/web/src/components/users/UsersPage.tsx +++ b/web/src/components/users/UsersPage.tsx @@ -23,7 +23,7 @@ import React from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; import { IssuesAlert, Page } from "~/components/core"; -import { FirstUser, RootAuthMethods } from "~/components/users"; +import { FirstUser, RootUser } from "~/components/users"; import { useIssues } from "~/queries/issues"; import { _ } from "~/i18n"; @@ -33,7 +33,7 @@ export default function UsersPage() { return ( - {_("Users")} + {_("Authentication")} @@ -43,7 +43,7 @@ export default function UsersPage() { - + diff --git a/web/src/components/users/index.ts b/web/src/components/users/index.ts index 064478fa80..e041f123df 100644 --- a/web/src/components/users/index.ts +++ b/web/src/components/users/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -21,8 +21,5 @@ */ export { default as FirstUser } from "./FirstUser"; -export { default as RootAuthMethods } from "./RootAuthMethods"; -export { default as RootAuthMethodsPage } from "./RootAuthMethodsPage"; -export { default as RootPasswordPopup } from "./RootPasswordPopup"; -export { default as RootSSHKeyPopup } from "./RootSSHKeyPopup"; +export { default as RootUser } from "./RootUser"; export { default as UsersPage } from "./UsersPage"; diff --git a/web/src/queries/users.ts b/web/src/queries/users.ts index 9263cb8b3b..7d27488a11 100644 --- a/web/src/queries/users.ts +++ b/web/src/queries/users.ts @@ -23,7 +23,7 @@ import React from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; -import { RootUser, RootUserChanges } from "~/types/users"; +import { RootUser } from "~/types/users"; import { fetchFirstUser, fetchRoot, @@ -117,13 +117,13 @@ const useRootUserMutation = () => { const queryClient = useQueryClient(); const query = { mutationFn: updateRoot, - onMutate: async (newRoot: RootUserChanges) => { + onMutate: async (newRoot: RootUser) => { await queryClient.cancelQueries({ queryKey: ["users", "root"] }); const previousRoot: RootUser = queryClient.getQueryData(["users", "root"]); queryClient.setQueryData(["users", "root"], { password: !!newRoot.password, - sshkey: newRoot.sshkey || previousRoot.sshkey, + sshPublicKey: newRoot.sshPublicKey || previousRoot.sshPublicKey, }); return { previousRoot }; }, @@ -150,7 +150,7 @@ const useRootUserChanges = () => { return client.onEvent((event) => { if (event.type === "RootChanged") { - const { password, sshkey } = event; + const { password, sshPublickKey } = event; queryClient.setQueryData(["users", "root"], (oldRoot: RootUser) => { const newRoot = { ...oldRoot }; if (password !== undefined) { @@ -158,8 +158,8 @@ const useRootUserChanges = () => { newRoot.hashedPassword = false; } - if (sshkey) { - newRoot.sshkey = sshkey; + if (sshPublickKey) { + newRoot.sshPublicKey = sshPublickKey; } return newRoot; diff --git a/web/src/router.tsx b/web/src/router.tsx index 55ec7f0ce2..ceaf7e1301 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -34,9 +34,8 @@ import registrationRoutes from "~/routes/registration"; import storageRoutes from "~/routes/storage"; import softwareRoutes from "~/routes/software"; import usersRoutes from "~/routes/users"; -import { ROOT as PATHS, USER } from "./routes/paths"; +import { ROOT as PATHS } from "./routes/paths"; import { N_ } from "~/i18n"; -import { RootAuthMethodsPage } from "~/components/users"; const rootRoutes = () => [ { @@ -69,13 +68,7 @@ const protectedRoutes = () => [ }, { element: , - children: [ - { - path: USER.rootUser.edit, - element: , - }, - productsRoutes(), - ], + children: [productsRoutes()], }, ], }, diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 7b51026490..711b1e41a7 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -55,13 +55,13 @@ const ROOT = { const USER = { root: "/users", - rootUser: { - edit: "/users/root/edit", - }, firstUser: { create: "/users/first", edit: "/users/first/edit", }, + rootUser: { + edit: "/users/root/edit", + }, }; const SOFTWARE = { @@ -98,7 +98,6 @@ const SIDE_PATHS = [ PRODUCT.progress, ROOT.installationProgress, ROOT.installationFinished, - USER.rootUser.edit, ]; export { L10N, NETWORK, PRODUCT, REGISTRATION, ROOT, SOFTWARE, STORAGE, USER, SIDE_PATHS }; diff --git a/web/src/routes/users.tsx b/web/src/routes/users.tsx index 05d398ccc4..1dbf8c51ce 100644 --- a/web/src/routes/users.tsx +++ b/web/src/routes/users.tsx @@ -23,6 +23,7 @@ import React from "react"; import UsersPage from "~/components/users/UsersPage"; import FirstUserForm from "~/components/users/FirstUserForm"; +import RootUserForm from "~/components/users/RootUserForm"; import { Route } from "~/types/routes"; import { USER as PATHS } from "~/routes/paths"; import { N_ } from "~/i18n"; @@ -30,7 +31,7 @@ import { N_ } from "~/i18n"; const routes = (): Route => ({ path: PATHS.root, handle: { - name: N_("Users"), + name: N_("Authentication"), icon: "manage_accounts", }, children: [ @@ -43,6 +44,10 @@ const routes = (): Route => ({ path: PATHS.firstUser.edit, element: , }, + { + path: PATHS.rootUser.edit, + element: , + }, ], }); diff --git a/web/src/types/users.ts b/web/src/types/users.ts index f6b09d22f4..5613a6405a 100644 --- a/web/src/types/users.ts +++ b/web/src/types/users.ts @@ -25,19 +25,12 @@ type FirstUser = { userName: string; password: string; hashedPassword: boolean; - autologin: boolean; }; type RootUser = { - password: boolean; - hashedPassword: boolean; - sshkey: string; -}; - -type RootUserChanges = { password: string; hashedPassword: boolean; - sshkey: string; + sshPublicKey: string; }; -export type { FirstUser, RootUserChanges, RootUser }; +export type { FirstUser, RootUser };