diff --git a/rust/WEB-SERVER.md b/rust/WEB-SERVER.md index 75534d03ee..8e91bc671a 100644 --- a/rust/WEB-SERVER.md +++ b/rust/WEB-SERVER.md @@ -68,10 +68,10 @@ $ curl http://localhost:3000/ping ### Authentication The web server uses a bearer token for HTTP authentication. You can get the token by providing your -password to the `/authenticate` endpoint. +password to the `/auth` endpoint. ``` -$ curl http://localhost:3000/authenticate \ +$ curl http://localhost:3000/api/auth \ -H "Content-Type: application/json" \ -d '{"password": "your-password"}' {"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDg1MTA5MzB9.3HmKAC5u4H_FigMqEa9e74OFAq40UldjlaExrOGqE0U"}⏎ diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index f46f5501fd..e1c85c0165 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -86,6 +86,15 @@ pub struct KeymapId { pub variant: Option, } +impl Default for KeymapId { + fn default() -> Self { + Self { + layout: "us".to_string(), + variant: None, + } + } +} + #[derive(Error, Debug, PartialEq)] #[error("Invalid keymap ID: {0}")] pub struct InvalidKeymap(String); diff --git a/rust/agama-server/src/l10n.rs b/rust/agama-server/src/l10n.rs index a5ebb08fe9..e1778aa567 100644 --- a/rust/agama-server/src/l10n.rs +++ b/rust/agama-server/src/l10n.rs @@ -7,15 +7,18 @@ pub mod web; use crate::error::Error; use agama_locale_data::{KeymapId, LocaleCode}; use anyhow::Context; -pub use keyboard::Keymap; use keyboard::KeymapsDatabase; -pub use locale::LocaleEntry; use locale::LocalesDatabase; -use std::process::Command; -pub use timezone::TimezoneEntry; +use regex::Regex; +use std::{io, process::Command}; use timezone::TimezonesDatabase; use zbus::{dbus_interface, Connection}; +pub use keyboard::Keymap; +pub use locale::LocaleEntry; +pub use timezone::TimezoneEntry; +pub use web::LocaleConfig; + pub struct Locale { timezone: String, timezones_db: TimezonesDatabase, @@ -24,6 +27,7 @@ pub struct Locale { keymap: KeymapId, keymaps_db: KeymapsDatabase, ui_locale: LocaleCode, + pub ui_keymap: KeymapId, } #[dbus_interface(name = "org.opensuse.Agama1.Locale")] @@ -211,6 +215,8 @@ impl Locale { let mut keymaps_db = KeymapsDatabase::new(); keymaps_db.read()?; + let ui_keymap = Self::x11_keymap().unwrap_or("us".to_string()); + let locale = Self { keymap: "us".parse().unwrap(), timezone: default_timezone, @@ -219,6 +225,7 @@ impl Locale { timezones_db, keymaps_db, ui_locale: ui_locale.clone(), + ui_keymap: ui_keymap.parse().unwrap_or_default(), }; Ok(locale) @@ -230,6 +237,24 @@ impl Locale { self.ui_locale = locale.clone(); Ok(()) } + + fn x11_keymap() -> Result { + let output = Command::new("setxkbmap") + .arg("-query") + .env("DISPLAY", ":0") + .output()?; + let output = String::from_utf8(output.stdout) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + let keymap_regexp = Regex::new(r"(?m)^layout: (.+)$").unwrap(); + let captures = keymap_regexp.captures(&output); + let keymap = captures + .and_then(|c| c.get(1).map(|e| e.as_str())) + .unwrap_or("us") + .to_string(); + + Ok(keymap) + } } pub async fn export_dbus_objects( diff --git a/rust/agama-server/src/l10n/web.rs b/rust/agama-server/src/l10n/web.rs index 1f84f4d437..6ce89a97e1 100644 --- a/rust/agama-server/src/l10n/web.rs +++ b/rust/agama-server/src/l10n/web.rs @@ -16,7 +16,10 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::sync::{Arc, RwLock}; +use std::{ + process::Command, + sync::{Arc, RwLock}, +}; use thiserror::Error; #[derive(Error, Debug)] @@ -29,6 +32,8 @@ pub enum LocaleError { InvalidKeymap(#[from] InvalidKeymap), #[error("Cannot translate: {0}")] OtherError(#[from] Error), + #[error("Cannot change the local keymap: {0}")] + CouldNotSetKeymap(#[from] std::io::Error), } impl IntoResponse for LocaleError { @@ -74,7 +79,7 @@ async fn locales(State(state): State) -> Json> { Json(locales) } -#[derive(Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct LocaleConfig { /// Locales to install in the target system locales: Option>, @@ -84,6 +89,8 @@ pub struct LocaleConfig { timezone: Option, /// User-interface locale. It is actually not related to the `locales` property. ui_locale: Option, + /// User-interface locale. It is relevant only on local installations. + ui_keymap: Option, } #[utoipa::path(get, path = "/l10n/timezones", responses( @@ -104,6 +111,8 @@ async fn keymaps(State(state): State) -> Json> { Json(keymaps) } +// TODO: update all or nothing +// TODO: send only the attributes that have changed #[utoipa::path(put, path = "/l10n/config", responses( (status = 200, description = "Set the locale configuration", body = LocaleConfig) ))] @@ -112,6 +121,7 @@ async fn set_config( Json(value): Json, ) -> Result, LocaleError> { let mut data = state.locale.write().unwrap(); + let mut changes = LocaleConfig::default(); if let Some(locales) = &value.locales { for loc in locales { @@ -120,6 +130,7 @@ async fn set_config( } } data.locales = locales.clone(); + changes.locales = Some(data.locales.clone()); } if let Some(timezone) = &value.timezone { @@ -127,10 +138,12 @@ async fn set_config( return Err(LocaleError::UnknownTimezone(timezone.to_string())); } data.timezone = timezone.to_owned(); + changes.timezone = Some(data.timezone.clone()); } if let Some(keymap_id) = &value.keymap { data.keymap = keymap_id.parse()?; + changes.keymap = Some(keymap_id.clone()); } if let Some(ui_locale) = &value.ui_locale { @@ -141,11 +154,25 @@ async fn set_config( helpers::set_service_locale(&locale); data.translate(&locale)?; + changes.ui_locale = Some(locale.to_string()); _ = state.events.send(Event::LocaleChanged { locale: locale.to_string(), }); } + if let Some(ui_keymap) = &value.ui_keymap { + data.ui_keymap = ui_keymap.parse()?; + Command::new("/usr/bin/localectl") + .args(["set-x11-keymap", &ui_keymap]) + .output()?; + Command::new("/usr/bin/setxkbmap") + .arg(&ui_keymap) + .env("DISPLAY", ":0") + .output()?; + } + + _ = state.events.send(Event::L10nConfigChanged(changes)); + Ok(Json(())) } @@ -159,6 +186,7 @@ async fn get_config(State(state): State) -> Json { keymap: Some(data.keymap()), timezone: Some(data.timezone().to_string()), ui_locale: Some(data.ui_locale().to_string()), + ui_keymap: Some(data.ui_keymap.to_string()), }) } diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index d8575a3339..a5fc089377 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -220,10 +220,15 @@ async fn get_config( State(state): State>, ) -> Result, SoftwareError> { let product = state.product.product().await?; + let product = if product.is_empty() { + None + } else { + Some(product) + }; let patterns = state.software.user_selected_patterns().await?; let config = SoftwareConfig { patterns: Some(patterns), - product: Some(product), + product: product, }; Ok(Json(config)) } diff --git a/rust/agama-server/src/web/auth.rs b/rust/agama-server/src/web/auth.rs index 5674bdc753..cdb3eba495 100644 --- a/rust/agama-server/src/web/auth.rs +++ b/rust/agama-server/src/web/auth.rs @@ -9,7 +9,7 @@ use axum::{ Json, RequestPartsExt, }; use axum_extra::{ - headers::{authorization::Bearer, Authorization}, + headers::{self, authorization::Bearer}, TypedHeader, }; use chrono::{Duration, Utc}; @@ -67,13 +67,25 @@ impl FromRequestParts for TokenClaims { parts: &mut request::Parts, state: &ServiceState, ) -> Result { - let TypedHeader(Authorization(bearer)) = parts - .extract::>>() + let token = match parts + .extract::>>() .await - .map_err(|_| AuthError::MissingToken)?; + { + Ok(TypedHeader(headers::Authorization(bearer))) => bearer.token().to_owned(), + Err(_) => { + let cookie = parts + .extract::>() + .await + .map_err(|_| AuthError::MissingToken)?; + cookie + .get("token") + .ok_or(AuthError::MissingToken)? + .to_owned() + } + }; let decoding = DecodingKey::from_secret(state.config.jwt_secret.as_ref()); - let token_data = jsonwebtoken::decode(bearer.token(), &decoding, &Validation::default())?; + let token_data = jsonwebtoken::decode(&token, &decoding, &Validation::default())?; Ok(token_data.claims) } diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index a84956c2f3..c94c3e42f6 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -1,3 +1,4 @@ +use crate::l10n::web::LocaleConfig; use agama_lib::{progress::Progress, software::SelectedBy}; use serde::Serialize; use std::collections::HashMap; @@ -6,6 +7,7 @@ use tokio::sync::broadcast::{Receiver, Sender}; #[derive(Clone, Serialize)] #[serde(tag = "type")] pub enum Event { + L10nConfigChanged(LocaleConfig), LocaleChanged { locale: String }, Progress(Progress), ProductChanged { id: String }, diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index 54da54a3ae..bbb71966c9 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -1,10 +1,15 @@ //! Implements the handlers for the HTTP-based API. use super::{ - auth::{generate_token, AuthError}, + auth::{generate_token, AuthError, TokenClaims}, state::ServiceState, }; -use axum::{extract::State, Json}; +use axum::{ + extract::State, + http::{header::SET_COOKIE, HeaderMap}, + response::IntoResponse, + Json, +}; use pam::Client; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -36,13 +41,13 @@ pub struct LoginRequest { pub password: String, } -#[utoipa::path(get, path = "/authenticate", responses( - (status = 200, description = "The user have been successfully authenticated", body = AuthResponse) +#[utoipa::path(post, path = "/api/auth", responses( + (status = 200, description = "The user has been successfully authenticated.", body = AuthResponse) ))] -pub async fn authenticate( +pub async fn login( State(state): State, Json(login): Json, -) -> Result, AuthError> { +) -> Result { let mut pam_client = Client::with_password("agama")?; pam_client .conversation_mut() @@ -50,5 +55,38 @@ pub async fn authenticate( pam_client.authenticate()?; let token = generate_token(&state.config.jwt_secret); - Ok(Json(AuthResponse { token })) + let content = Json(AuthResponse { + token: token.to_owned(), + }); + + let mut headers = HeaderMap::new(); + let cookie = format!("token={}; HttpOnly", &token); + headers.insert( + SET_COOKIE, + cookie.parse().expect("could not build a valid cookie"), + ); + + Ok((headers, content)) +} + +#[utoipa::path(delete, path = "/api/auth", responses( + (status = 204, description = "The user has been logged out.") +))] +pub async fn logout(_claims: TokenClaims) -> Result { + let mut headers = HeaderMap::new(); + let cookie = "token=deleted; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT".to_string(); + headers.insert( + SET_COOKIE, + cookie.parse().expect("could not build a valid cookie"), + ); + Ok(headers) +} + +/// Check whether the user is authenticated. +#[utoipa::path(get, path = "/api/auth", responses( + (status = 200, description = "The user is authenticated."), + (status = 400, description = "The user is not authenticated.") +))] +pub async fn session(_claims: TokenClaims) -> Result<(), AuthError> { + Ok(()) } diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index 3af07c6fe9..d28ebd3c7d 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -1,3 +1,4 @@ +use super::http::{login, logout, session}; use super::{auth::TokenClaims, config::ServiceConfig, state::ServiceState, EventsSender}; use axum::{ extract::Request, @@ -19,7 +20,7 @@ use tower_http::{compression::CompressionLayer, services::ServeDir, trace::Trace /// /// * A static assets directory (`public_dir`). /// * A websocket at the `/ws` path. -/// * An authentication endpoint at `/authenticate`. +/// * An authentication endpoint at `/auth`. /// * A 'ping' endpoint at '/ping'. /// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { @@ -81,7 +82,7 @@ impl MainServiceBuilder { state.clone(), )) .route("/ping", get(super::http::ping)) - .route("/authenticate", post(super::http::authenticate)); + .route("/auth", post(login).get(session).delete(logout)); let serve = ServeDir::new(self.public_dir); Router::new() diff --git a/web/src/App.jsx b/web/src/App.jsx index 8a333563a3..6ab2d45fba 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -24,8 +24,8 @@ import { Outlet } from "react-router-dom"; import { useInstallerClient, useInstallerClientStatus } from "~/context/installer"; import { useProduct } from "./context/product"; -import { STARTUP, INSTALL } from "~/client/phase"; -import { BUSY } from "~/client/status"; +import { CONFIG, INSTALL, STARTUP } from "~/client/phase"; +import { BUSY, IDLE } from "~/client/status"; import { DBusError, If, Installation } from "~/components/core"; import { Loading } from "./components/layout"; @@ -51,22 +51,29 @@ function App() { useEffect(() => { if (client) { - return client.manager.onPhaseChange(setPhase); + // FIXME: adapt to the new API + // return client.manager.onPhaseChange(setPhase); + setPhase(CONFIG); } }, [client, setPhase]); useEffect(() => { if (client) { - return client.manager.onStatusChange(setStatus); + setStatus(IDLE); + // FIXME: adapt to the new API + // return client.manager.onStatusChange(setStatus); } }, [client, setStatus]); useEffect(() => { const loadPhase = async () => { - const phase = await client.manager.getPhase(); - const status = await client.manager.getStatus(); - setPhase(phase); - setStatus(status); + setPhase(CONFIG); + setStatus(IDLE); + // FIXME: adapt to the new API + // const phase = await client.manager.getPhase(); + // const status = await client.manager.getStatus(); + // setPhase(phase); + // setStatus(status); }; if (client) { diff --git a/web/src/Main.jsx b/web/src/Main.jsx index da74af2da1..92be6efc29 100644 --- a/web/src/Main.jsx +++ b/web/src/Main.jsx @@ -21,15 +21,17 @@ import React from "react"; import { Outlet } from "react-router-dom"; -import { Questions } from "~/components/questions"; +// import { Questions } from "~/components/questions"; function Main() { - return ( - <> - - - - ); + // FIXME: adapt to the new API + // return ( + // <> + // + // + // + // ); + return ; } export default Main; diff --git a/web/src/Protected.jsx b/web/src/Protected.jsx new file mode 100644 index 0000000000..0b2a9c5f99 --- /dev/null +++ b/web/src/Protected.jsx @@ -0,0 +1,39 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "./context/auth"; +import { AppProviders } from "./context/app"; + +export default function Protected() { + const { isLoggedIn } = useAuth(); + + if (isLoggedIn !== true) { + return ; + } + + return ( + + + + ); +} diff --git a/web/src/assets/styles/patternfly-overrides.scss b/web/src/assets/styles/patternfly-overrides.scss index 9127b55a5b..607f945e0e 100644 --- a/web/src/assets/styles/patternfly-overrides.scss +++ b/web/src/assets/styles/patternfly-overrides.scss @@ -90,7 +90,6 @@ table td > .pf-v5-c-empty-state { padding-block: var(--pf-v5-c-modal-box__body--PaddingTop); } -.pf-v5-c-form__actions, .pf-v5-c-modal-box__footer { // We prefer buttons placed at the right flex-direction: row-reverse; @@ -225,6 +224,10 @@ table td > .pf-v5-c-empty-state { } } +.pf-v5-c-form__group.pf-m-action { + --pf-v5-c-form__group--m-action--MarginTop: var(--spacer-small); +} + .pf-v5-c-form__group-label-help { margin: 0; padding: 0; diff --git a/web/src/client/http.js b/web/src/client/http.js new file mode 100644 index 0000000000..99a63bc571 --- /dev/null +++ b/web/src/client/http.js @@ -0,0 +1,157 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 + +/** + * @callback RemoveFn + * @return {void} + */ + +/** + * Agama WebSocket client. + * + * Connects to the Agama WebSocket server and reacts on the events. + * This class is not expected to be used directly, but through the + * HTTPClient API. + */ +class WSClient { + /** + * @param {URL} url - Websocket URL. + */ + constructor(url) { + this.client = new WebSocket(url.toString()); + this.client.onmessage = (event) => { + this.dispatchEvent(event); + }; + this.handlers = []; + } + + /** + * Registers a handler for events. + * + * The handler is executed for all the events. It is up to the callback to + * filter the relevant events. + * + * @param {(object) => void} func - Handler function to register. + * @return {RemoveFn} + */ + onEvent(func) { + this.handlers.push(func); + return () => { + const position = this.handlers.indexOf(func); + if (position > -1) this.handlers.splice(position, 1); + }; + } + + /** + * @private + * + * Dispatchs an event by running all the handlers. + * + * @param {object} event - Event object, which is basically a websocket message. + */ + dispatchEvent(event) { + const eventObject = JSON.parse(event.data); + this.handlers.forEach((f) => f(eventObject)); + } +} + +/** + * Agama HTTP API client. + */ +class HTTPClient { + /** + * @param {URL} url - URL of the HTTP API. + */ + constructor(url) { + const httpUrl = new URL(url.toString()); + httpUrl.pathname = url.pathname.concat("api"); + this.baseUrl = httpUrl.toString(); + + const wsUrl = new URL(url.toString()); + wsUrl.pathname = wsUrl.pathname.concat("api/ws"); + wsUrl.protocol = (url.protocol === "http:") ? "ws" : "wss"; + this.ws = new WSClient(wsUrl); + } + + /** + * @param {string} url - Endpoint URL (e.g., "/l10n/config"). + * @return {Promise} Server response. + */ + async get(url) { + const response = await fetch(`${this.baseUrl}/${url}`, { + headers: { + "Content-Type": "application/json", + }, + }); + return await response.json(); + } + + /** + * @param {string} url - Endpoint URL (e.g., "/l10n/config"). + * @param {object} data - Data to submit + * @return {Promise} Server response. + */ + async post(url, data) { + const response = await fetch(`${this.baseUrl}/${url}`, { + method: "POST", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json", + }, + }); + return await response.json(); + } + + /** + * @param {string} url - Endpoint URL (e.g., "/l10n/config"). + * @param {object} data - Data to submit + * @return {Promise} Server response. + */ + async put(url, data) { + const response = await fetch(`${this.baseUrl}/${url}`, { + method: "PUT", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json", + }, + }); + return await response.json(); + } + + /** + * Registers a handler for a given type of events. + * + * @param {string} type - Event type (e.g., "LocaleChanged"). + * @param {(object) => void} func - Handler function to register. + * @return {RemoveFn} - Function to remove the handler. + */ + onEvent(type, func) { + return this.ws.onEvent((event) => { + if (event.type === type) { + func(event); + } + }); + } +} + +export { HTTPClient }; diff --git a/web/src/client/index.js b/web/src/client/index.js index 0f64ac1bcb..be43839529 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -24,16 +24,14 @@ import { L10nClient } from "./l10n"; import { ManagerClient } from "./manager"; import { Monitor } from "./monitor"; -import { SoftwareClient } from "./software"; +import { ProductClient, SoftwareClient } from "./software"; import { StorageClient } from "./storage"; import { UsersClient } from "./users"; import phase from "./phase"; import { QuestionsClient } from "./questions"; import { NetworkClient } from "./network"; import cockpit from "../lib/cockpit"; - -const BUS_ADDRESS_FILE = "/run/agama/bus.address"; -const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; +import { HTTPClient } from "./http"; /** * @typedef {object} InstallerClient @@ -41,6 +39,7 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; * @property {ManagerClient} manager - manager client. * @property {Monitor} monitor - service monitor. * @property {NetworkClient} network - network client. + * @property {ProductClient} product - product client. * @property {SoftwareClient} software - software client. * @property {StorageClient} storage - storage client. * @property {UsersClient} users - users client. @@ -63,22 +62,26 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; * @property {Issue[]} [software] - Issues from software. * * @typedef {(issues: Issues) => void} IssuesHandler -*/ + */ /** * Creates the Agama client * + * @param {URL} url - URL of the HTTP API. * @return {InstallerClient} */ -const createClient = (address = "unix:path=/run/agama/bus") => { - const l10n = new L10nClient(address); - const manager = new ManagerClient(address); - const monitor = new Monitor(address, MANAGER_SERVICE); - const network = new NetworkClient(address); - const software = new SoftwareClient(address); - const storage = new StorageClient(address); - const users = new UsersClient(address); - const questions = new QuestionsClient(address); +const createClient = (url) => { + const client = new HTTPClient(url); + const l10n = new L10nClient(client); + // TODO: unify with the manager client + const product = new ProductClient(client); + // const manager = new ManagerClient(address); + // const monitor = new Monitor(address, MANAGER_SERVICE); + // const network = new NetworkClient(address); + // const software = new SoftwareClient(address); + // const storage = new StorageClient(address); + // const users = new UsersClient(address); + // const questions = new QuestionsClient(address); /** * Gets all issues, grouping them by context. @@ -88,13 +91,13 @@ const createClient = (address = "unix:path=/run/agama/bus") => { * * @returns {Promise} */ - const issues = async () => { - return { - product: await software.product.getIssues(), - storage: await storage.getIssues(), - software: await software.getIssues() - }; - }; + // const issues = async () => { + // return { + // product: await software.product.getIssues(), + // storage: await storage.getIssues(), + // software: await software.getIssues(), + // }; + // }; /** * Registers a callback to be executed when issues change. @@ -105,42 +108,55 @@ const createClient = (address = "unix:path=/run/agama/bus") => { const onIssuesChange = (handler) => { const unsubscribeCallbacks = []; - unsubscribeCallbacks.push(software.product.onIssuesChange(i => handler({ product: i }))); - unsubscribeCallbacks.push(storage.onIssuesChange(i => handler({ storage: i }))); - unsubscribeCallbacks.push(software.onIssuesChange(i => handler({ software: i }))); + // unsubscribeCallbacks.push( + // software.product.onIssuesChange((i) => handler({ product: i })), + // ); + // unsubscribeCallbacks.push( + // storage.onIssuesChange((i) => handler({ storage: i })), + // ); + // unsubscribeCallbacks.push( + // software.onIssuesChange((i) => handler({ software: i })), + // ); - return () => { unsubscribeCallbacks.forEach(cb => cb()) }; + return () => { + unsubscribeCallbacks.forEach((cb) => cb()); + }; }; const isConnected = async () => { - try { - await manager.getStatus(); - return true; - } catch (e) { - return false; - } + // try { + // await manager.getStatus(); + // return true; + // } catch (e) { + // return false; + // } + return true; }; return { l10n, - manager, - monitor, - network, - software, - storage, - users, - questions, - issues, + product, + // manager, + // monitor, + // network, + // software, + // storage, + // users, + // questions, + // issues, onIssuesChange, isConnected, - onDisconnect: (handler) => monitor.onDisconnect(handler) + onDisconnect: (handler) => { + return () => {}; + }, + // onDisconnect: (handler) => monitor.onDisconnect(handler), }; }; const createDefaultClient = async () => { - const file = cockpit.file(BUS_ADDRESS_FILE); - const address = await file.read(); - return (address) ? createClient(address) : createClient(); + const httpUrl = new URL(window.location.toString()); + httpUrl.hash = ""; + return createClient(httpUrl); }; export { createClient, createDefaultClient, phase }; diff --git a/web/src/client/l10n.js b/web/src/client/l10n.js index dfab3001ce..d1f7e79d12 100644 --- a/web/src/client/l10n.js +++ b/web/src/client/l10n.js @@ -20,13 +20,8 @@ */ // @ts-check -import DBusClient from "./dbus"; import { timezoneUTCOffset } from "~/utils"; -const LOCALE_SERVICE = "org.opensuse.Agama1"; -const LOCALE_IFACE = "org.opensuse.Agama1.Locale"; -const LOCALE_PATH = "/org/opensuse/Agama1/Locale"; - /** * @typedef {object} Timezone * @property {string} id - Timezone id (e.g., "Atlantic/Canary"). @@ -53,31 +48,54 @@ const LOCALE_PATH = "/org/opensuse/Agama1/Locale"; */ class L10nClient { /** - * @param {string|undefined} address - D-Bus address; if it is undefined, it uses the system bus. + * @param {import("./http").HTTPClient} client - HTTP client. */ - constructor(address = undefined) { - this.client = new DBusClient(LOCALE_SERVICE, address); + constructor(client) { + this.client = client; } /** * Selected locale to translate the installer UI. * - * @return {Promise} Locale id. + * @return {Promise} Locale ID. */ async getUILocale() { - const proxy = await this.client.proxy(LOCALE_IFACE); - return proxy.UILocale; + const config = await this.client.get("/l10n/config"); + return config.ui_locale; } /** * Sets the locale to translate the installer UI. * - * @param {String} id - Locale id. + * @param {String} id - Locale ID. * @return {Promise} */ async setUILocale(id) { - const proxy = await this.client.proxy(LOCALE_IFACE); - proxy.UILocale = id; + return this.client.put("/l10n/config", { ui_locale: id }); + } + + /** + * Selected keymap for the installer. + * + * This setting is only relevant in the local installation. + * + * @return {Promise} Keymap ID. + */ + async getUIKeymap() { + const config = await this.client.get("/l10n/config"); + return config.ui_locale; + } + + /** + * Sets the keymap to use in the installer. + * + * This setting is only relevant in the local installation. + * + * @param {String} id - Keymap ID. + * @return {Promise} + */ + async setUIKeymap(id) { + return this.client.put("/l10n/config", { ui_keymap: id }); } /** @@ -86,9 +104,7 @@ class L10nClient { * @return {Promise>} */ async timezones() { - const proxy = await this.client.proxy(LOCALE_IFACE); - const timezones = await proxy.ListTimezones(); - + const timezones = await this.client.get("/l10n/timezones"); return timezones.map(this.buildTimezone); } @@ -98,8 +114,8 @@ class L10nClient { * @return {Promise} Id of the timezone. */ async getTimezone() { - const proxy = await this.client.proxy(LOCALE_IFACE); - return proxy.Timezone; + const config = await this.getConfig(); + return config.timezone; } /** @@ -109,19 +125,18 @@ class L10nClient { * @return {Promise} */ async setTimezone(id) { - const proxy = await this.client.proxy(LOCALE_IFACE); - proxy.Timezone = id; + this.setConfig({ timezone: id }); } /** * Available locales to install in the target system. * + * TODO: find a better name because it is rather confusing (e.g., 'locales' and 'getLocales'). + * * @return {Promise>} */ async locales() { - const proxy = await this.client.proxy(LOCALE_IFACE); - const locales = await proxy.ListLocales(); - + const locales = await this.client.get("/l10n/locales"); return locales.map(this.buildLocale); } @@ -131,8 +146,8 @@ class L10nClient { * @return {Promise>} Ids of the locales. */ async getLocales() { - const proxy = await this.client.proxy(LOCALE_IFACE); - return proxy.Locales; + const config = await this.getConfig(); + return config.locales; } /** @@ -142,8 +157,7 @@ class L10nClient { * @return {Promise} */ async setLocales(ids) { - const proxy = await this.client.proxy(LOCALE_IFACE); - proxy.Locales = ids; + this.setConfig({ locales: ids }); } /** @@ -155,9 +169,7 @@ class L10nClient { * @return {Promise>} */ async keymaps() { - const proxy = await this.client.proxy(LOCALE_IFACE); - const keymaps = await proxy.ListKeymaps(); - + const keymaps = await this.client.get("/l10n/keymaps"); return keymaps.map(this.buildKeymap); } @@ -167,8 +179,8 @@ class L10nClient { * @return {Promise} Id of the keymap. */ async getKeymap() { - const proxy = await this.client.proxy(LOCALE_IFACE); - return proxy.Keymap; + const config = await this.getConfig(); + return config.keymap; } /** @@ -178,86 +190,111 @@ class L10nClient { * @return {Promise} */ async setKeymap(id) { - const proxy = await this.client.proxy(LOCALE_IFACE); - - proxy.Keymap = id; + this.setConfig({ keymap: id }); } /** - * Register a callback to run when Timezone D-Bus property changes. + * Register a callback to run when the timezone configuration changes. * * @param {(timezone: string) => void} handler - Function to call when Timezone changes. - * @return {import ("./dbus").RemoveFn} Function to disable the callback. + * @return {import ("./http").RemoveFn} Function to disable the callback. */ onTimezoneChange(handler) { - return this.client.onObjectChanged(LOCALE_PATH, LOCALE_IFACE, changes => { - if ("Timezone" in changes) { - const id = changes.Timezone.v; - handler(id); + return this.client.onEvent("L10nConfigChanged", ({ timezone }) => { + if (timezone) { + handler(timezone); } }); } /** - * Register a callback to run when Locales D-Bus property changes. + * Register a callback to run when the locales configuration changes. * - * @param {(language: string) => void} handler - Function to call when Locales changes. - * @return {import ("./dbus").RemoveFn} Function to disable the callback. + * @param {(locales: string[]) => void} handler - Function to call when Locales changes. + * @return {import ("./http").RemoveFn} Function to disable the callback. */ onLocalesChange(handler) { - return this.client.onObjectChanged(LOCALE_PATH, LOCALE_IFACE, changes => { - if ("Locales" in changes) { - const selectedIds = changes.Locales.v; - handler(selectedIds); + return this.client.onEvent("L10nConfigChanged", ({ locales }) => { + if (locales) { + handler(locales); } }); } /** - * Register a callback to run when Keymap D-Bus property changes. + * Register a callback to run when the keymap configuration changes. * - * @param {(language: string) => void} handler - Function to call when Keymap changes. - * @return {import ("./dbus").RemoveFn} Function to disable the callback. + * @param {(keymap: string) => void} handler - Function to call when Keymap changes. + * @return {import ("./http").RemoveFn} Function to disable the callback. */ onKeymapChange(handler) { - return this.client.onObjectChanged(LOCALE_PATH, LOCALE_IFACE, changes => { - if ("Keymap" in changes) { - const id = changes.Keymap.v; - handler(id); + return this.client.onEvent("L10nConfigChanged", ({ keymap }) => { + if (keymap) { + handler(keymap); } }); } + /** + * @private + * Convenience method to get l10n the configuration. + * + * @return {Promise} Localization configuration. + */ + async getConfig() { + return await this.client.get("/l10n/config"); + } + + /** + * @private + * + * Convenience method to set l10n the configuration. + * + * @param {object} data - Configuration to update. It can just part of the configuration. + * @return {Promise} + */ + async setConfig(data) { + return this.client.put("/l10n/config", data); + } + /** * @private * - * @param {[string, Array, string]} dbusTimezone - * @returns {Timezone} + * @param {object} timezone - Timezone data. + * @param {string} timezone.code - Timezone identifier. + * @param {Array} timezone.parts - Localized parts of the timezone identifier. + * @param {string} timezone.country - Timezone country. + * @return {Timezone} */ - buildTimezone([id, parts, country]) { - const utcOffset = timezoneUTCOffset(id); + buildTimezone({ code, parts, country }) { + const utcOffset = timezoneUTCOffset(code); - return ({ id, parts, country, utcOffset }); + return ({ id: code, parts, country, utcOffset }); } /** * @private * - * @param {[string, string, string]} dbusLocale - * @returns {Locale} + * @param {object} locale - Locale data. + * @param {string} locale.code - Identifier. + * @param {string} locale.name - Name. + * @param {string} locale.territory - Territory. + * @return {Locale} */ - buildLocale([id, name, territory]) { - return ({ id, name, territory }); + buildLocale({ code, name, territory }) { + return ({ id: code, name, territory }); } /** * @private * - * @param {[string, string]} dbusKeymap - * @returns {Keymap} + * @param {object} keymap - Keymap data + * @param {string} keymap.id - Id (e.g., "us"). + * @param {string} keymap.description - Keymap description (e.g., "English (US)"). + * @return {Keymap} */ - buildKeymap([id, name]) { - return ({ id, name }); + buildKeymap({ id, description }) { + return ({ id, name: description }); } } diff --git a/web/src/client/software.js b/web/src/client/software.js index 56ca3578a8..b8d8b1eb1f 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -297,8 +297,62 @@ class SoftwareBaseClient { */ class SoftwareClient extends WithIssues( WithProgress( - WithStatus(SoftwareBaseClient, SOFTWARE_PATH), SOFTWARE_PATH - ), SOFTWARE_PATH -) { } + WithStatus(SoftwareBaseClient, SOFTWARE_PATH), + SOFTWARE_PATH, + ), + SOFTWARE_PATH, +) {} -export { SoftwareClient }; +class ProductClient { + /** + * @param {import("./http").HTTPClient} client - HTTP client. + */ + constructor(client) { + this.client = client; + } + + /** + * Returns the list of available products. + * + * @return {Promise>} + */ + async getAll() { + const products = await this.client.get("/software/products"); + return products; + } + + /** + * Returns the identifier of the selected product. + * + * @return {Promise} Selected identifier. + */ + async getSelected() { + const config = await this.client.get("/software/config"); + return config.product; + } + + /** + * Selects a product for installation. + * + * @param {string} id - Product ID. + */ + async select(id) { + await this.client.put("/software/config", { product: id }); + } + + /** + * Registers a callback to run when the select product changes. + * + * @param {(id: string) => void} handler - Callback function. + * @return {import ("./http").RemoveFn} Function to remove the callback. + */ + onChange(handler) { + return this.client.onEvent("ProductChanged", ({ id }) => { + if (id) { + handler(id); + } + }); + } +} + +export { ProductClient, SoftwareClient }; diff --git a/web/src/components/core/About.jsx b/web/src/components/core/About.jsx index be97bd7892..75b77808bd 100644 --- a/web/src/components/core/About.jsx +++ b/web/src/components/core/About.jsx @@ -19,15 +19,36 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React, { useState } from "react"; import { Button, Text } from "@patternfly/react-core"; +import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; - import { Icon } from "~/components/layout"; import { Popup } from "~/components/core"; -import { _ } from "~/i18n"; -export default function About() { +/** + * @typedef {import("@patternfly/react-core").ButtonProps} ButtonProps + */ + +/** + * Renders a button and a dialog to allow user read about what Agama is + * @component + * + * @param {object} props + * @param {boolean} [props.showIcon=true] - Whether render a "help" icon into the button. + * @param {string} [props.iconSize="s"] - The size for the button icon. + * @param {string} [props.buttonText="About Agama"] - The text for the button. + * @param {ButtonProps["variant"]} [props.buttonVariant="link"] - The button variant. + * See {@link https://www.patternfly.org/components/button#variant-examples PF/Button}. + */ +export default function About({ + showIcon = true, + iconSize = "s", + buttonText = _("About Agama"), + buttonVariant = "link" +}) { const [isOpen, setIsOpen] = useState(false); const open = () => setIsOpen(true); @@ -36,11 +57,11 @@ export default function About() { return ( <> { + it("renders a help icon inside the button by default", () => { + const { container } = plainRender(); + const icon = container.querySelector('svg'); + expect(icon).toHaveAttribute("data-icon-name", "help"); + }); + + it("does not render a help icon inside the button if showIcon=false", () => { + const { container } = plainRender(); + const icon = container.querySelector('svg'); + expect(icon).toBeNull(); + }); + + it("allows setting its icon size", () => { + const { container } = plainRender(); + const icon = container.querySelector('svg'); + expect(icon.classList.contains("icon-xxs")).toBe(true); + }); + + it("allows setting its button text", () => { + plainRender(); + screen.getByRole("button", { name: "What is this?" }); + }); + + it("allows setting its button variant", () => { + plainRender(); + const button = screen.getByRole("button", { name: "About Agama" }); + expect(button.classList.contains("pf-m-tertiary")).toBe(true); + }); + it("allows user to read 'About Agama'", async () => { const { user } = plainRender(); diff --git a/web/src/components/core/If.jsx b/web/src/components/core/If.jsx index c53991d1f8..3cc73895cf 100644 --- a/web/src/components/core/If.jsx +++ b/web/src/components/core/If.jsx @@ -59,7 +59,7 @@ * ... * * @param {object} props - * @param {boolean} props.condition + * @param {any} props.condition * @param {JSX.Element} [props.then=null] - the content to be rendered when the condition is true * @param {JSX.Element} [props.else=null] - the content to be rendered when the condition is false */ diff --git a/web/src/components/core/LoginPage.jsx b/web/src/components/core/LoginPage.jsx new file mode 100644 index 0000000000..896224007d --- /dev/null +++ b/web/src/components/core/LoginPage.jsx @@ -0,0 +1,100 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 } from "react"; +import { Navigate } from "react-router-dom"; +import { + ActionGroup, + Button, + Form, + FormGroup, +} from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; +import { useAuth } from "~/context/auth"; +import { About, Page, PasswordInput, Section } from "~/components/core"; +import { Center } from "~/components/layout"; + +// @ts-check + +/** + * Renders the UI that lets the user log into the system. + * @component + */ +export default function LoginPage() { + const [password, setPassword] = useState(""); + const { isLoggedIn, login: loginFn } = useAuth(); + + const login = async (e) => { + e.preventDefault(); + await loginFn(password); + }; + + if (isLoggedIn) { + return ; + } + + // TRANSLATORS: Title for a form to provide the password for the root user. %s + // will be replaced by "root" + const sectionTitle = sprintf(_("Login as %s"), "root"); + return ( + +
+
+

root" + ) + }} + /> + +

+ + setPassword(v)} + /> + + + + + +
+
+
+ + + + +
+ ); +} diff --git a/web/src/components/core/LoginPage.test.jsx b/web/src/components/core/LoginPage.test.jsx new file mode 100644 index 0000000000..4f781cba4c --- /dev/null +++ b/web/src/components/core/LoginPage.test.jsx @@ -0,0 +1,90 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { LoginPage } from "~/components/core"; + +let mockIsAuthenticated; +const mockLoginFn = jest.fn(); + +jest.mock("~/context/auth", () => ({ + ...jest.requireActual("~/context/auth"), + useAuth: () => { + return { + isAuthenticated: mockIsAuthenticated, + login: mockLoginFn + }; + } +})); + +describe("LoginPage", () => { + beforeAll(() => { + mockIsAuthenticated = false; + jest.spyOn(console, "error").mockImplementation(); + }); + + afterAll(() => { + console.error.mockRestore(); + }); + + describe("when user is not authenticated", () => { + it("renders reference to root", () => { + plainRender(); + screen.getAllByText(/root/); + }); + + it("allows entering a password", async () => { + const { user } = plainRender(); + const form = screen.getByRole("form", { name: "Login form" }); + const passwordInput = within(form).getByLabelText("Password input"); + const loginButton = within(form).getByRole("button", { name: "Log in" }); + + await user.type(passwordInput, "s3cr3t"); + await user.click(loginButton); + + expect(mockLoginFn).toHaveBeenCalledWith("s3cr3t"); + }); + + it("renders a button to know more about the project", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "What is this?" }); + + await user.click(button); + + const dialog = await screen.findByRole("dialog"); + within(dialog).getByText(/About/); + }); + }); + + describe("when user is already authenticated", () => { + beforeEach(() => { + mockIsAuthenticated = true; + }); + + it("redirects to root route", async () => { + plainRender(); + // react-router-dom Navigate is mocked. See test-utils for more details. + await screen.findByText("Navigating to /"); + }); + }); +}); diff --git a/web/src/components/core/Page.jsx b/web/src/components/core/Page.jsx index 8d57e1ea85..e425ca5757 100644 --- a/web/src/components/core/Page.jsx +++ b/web/src/components/core/Page.jsx @@ -19,16 +19,22 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@patternfly/react-core"; - import { _ } from "~/i18n"; import { partition } from "~/utils"; import { Icon } from "~/components/layout"; import { If, PageMenu, Sidebar } from "~/components/core"; +// @ts-ignore import logoUrl from "~/assets/suse-horizontal-logo.svg"; +/** + * @typedef {import("@patternfly/react-core").ButtonProps} ButtonProps + */ + /** * Wrapper component for holding Page actions * @@ -37,8 +43,8 @@ import logoUrl from "~/assets/suse-horizontal-logo.svg"; * * @see Page examples. * - * @param {object} [props] - component props - * @param {React.ReactNode} [props.children] - components to be rendered as actions + * @param {object} props - Component props. + * @param {React.ReactNode} props.children - Components to be rendered as actions. */ const Actions = ({ children }) => <>{children}; @@ -59,17 +65,20 @@ const Menu = PageMenu; * * @see Page examples. * - * @param {object} [props] - Component props. - * @param {function} [props.onClick] - Callback to be triggered when action is clicked. - * @param {string} [props.navigateTo] - Route to navigate after triggering the onClick callback, if given. - * @param {React.ReactNode} props.children - Content of the action. - * @param {object} [props.props] - other props passed down to the internal PF/Button. See {@link https://www.patternfly.org/components/button/#button}. + * @typedef {object} ActionProps + * @property {string} [navigateTo] + * + * @typedef {ActionProps & ButtonProps} PageActionProps + * + * @param {PageActionProps} props */ -const Action = ({ navigateTo, onClick, children, ...props }) => { +const Action = ({ navigateTo, children, ...props }) => { const navigate = useNavigate(); - props.onClick = () => { - if (typeof onClick === "function") onClick(); + const onClickFn = props.onClick; + + props.onClick = (e) => { + if (typeof onClickFn === "function") onClickFn(e); if (navigateTo) navigate(navigateTo); }; @@ -152,10 +161,16 @@ const BackAction = () => { * @param {string} [props.icon] - The icon for the page. * @param {string} [props.title="Agama"] - The title for the page. By default it * uses the name of the tool, do not mark it for translation. - * @param {JSX.Element} [props.children] - The page content. + * @param {boolean} [props.mountSidebar=true] - Whether include the core/Sidebar component. + * @param {JSX.Element[]} [props.children] - The page content. * */ -const Page = ({ icon, title = "Agama", children }) => { +const Page = ({ + icon, + title = "Agama", + mountSidebar = true, + children +}) => { const [sidebarOpen, setSidebarOpen] = useState(false); /** @@ -195,15 +210,20 @@ const Page = ({ icon, title = "Agama", children }) => {
{ menu } - + + + + } + />
@@ -218,7 +238,10 @@ const Page = ({ icon, title = "Agama", children }) => { Logo of SUSE - + } + /> ); }; diff --git a/web/src/components/core/Page.test.jsx b/web/src/components/core/Page.test.jsx index 36949c1a1c..6b9b7397f3 100644 --- a/web/src/components/core/Page.test.jsx +++ b/web/src/components/core/Page.test.jsx @@ -120,14 +120,22 @@ describe("Page", () => { screen.getByRole("button", { name: "Back" }); }); - it("renders the Agama sidebar", async () => { + it("renders the Agama sidebar by default", async () => { const { user } = installerRender(, { withL10n: true }); - const openSidebarButton = screen.getByRole("button", { name: "Show global options" }); await user.click(openSidebarButton); + screen.getByRole("complementary", { name: /options/i }); }); + + it("does not render the Agama sidebar when mountSidebar=false", () => { + installerRender(, { withL10n: true }); + const openSidebarButton = screen.queryByRole("button", { name: "Show global options" }); + const sidebar = screen.queryByRole("complementary", { name: /options/i, hidden: true }); + expect(openSidebarButton).toBeNull(); + expect(sidebar).toBeNull(); + }); }); describe("Page.Actions", () => { diff --git a/web/src/components/core/PasswordInput.jsx b/web/src/components/core/PasswordInput.jsx index 3e641e4f98..23f95f4b14 100644 --- a/web/src/components/core/PasswordInput.jsx +++ b/web/src/components/core/PasswordInput.jsx @@ -19,24 +19,28 @@ * find current contact information at www.suse.com. */ +// @ts-check + +import React, { useState } from "react"; +import { Button, InputGroup, TextInput } from "@patternfly/react-core"; +import { _ } from "~/i18n"; +import { Icon } from "~/components/layout"; + +/** + * @typedef {import("@patternfly/react-core").TextInputProps} TextInputProps + * + * Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, + * except `type` that will be forced to 'password'. + * @typedef {Omit} PasswordInputProps + */ + /** * Renders a password input field and a toggle button that can be used to reveal * and hide the password * @component * - * @param {string} id - the identifier for the field. - * @param {Object} props - props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, - * except `type` that will be ignored. + * @param {PasswordInputProps} props */ -import React, { useState } from "react"; -import { - Button, - InputGroup, - TextInput -} from "@patternfly/react-core"; -import { Icon } from "~/components/layout"; -import { _ } from "~/i18n"; - export default function PasswordInput({ id, ...props }) { const [showPassword, setShowPassword] = useState(false); const visibilityIconName = showPassword ? "visibility_off" : "visibility"; diff --git a/web/src/components/core/Popup.jsx b/web/src/components/core/Popup.jsx index 78cdca2e8b..4c60cc01b0 100644 --- a/web/src/components/core/Popup.jsx +++ b/web/src/components/core/Popup.jsx @@ -19,12 +19,19 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React from "react"; import { Button, Modal } from "@patternfly/react-core"; - import { _ } from "~/i18n"; import { partition } from "~/utils"; +/** + * @typedef {import("@patternfly/react-core").ModalProps} ModalProps + * @typedef {import("@patternfly/react-core").ButtonProps} ButtonProps + * @typedef {Omit} ButtonWithoutVariantProps + */ + /** * Wrapper component for holding Popup actions * @@ -33,6 +40,7 @@ import { partition } from "~/utils"; * * @see Popup examples. * + * @param {object} props * @param {React.ReactNode} [props.children] - a collection of Action components */ const Actions = ({ children }) => <>{children}; @@ -42,13 +50,10 @@ const Actions = ({ children }) => <>{children}; * * Built on top of {@link https://www.patternfly.org/components/button PF/Button} * - * @see Popup examples. - * - * @param {React.ReactNode} props.children - content of the action - * @param {object} [props] - PF/Button props, see {@link https://www.patternfly.org/components/button#props} + * @param {ButtonProps} props */ -const Action = ({ children, ...props }) => ( - ); @@ -68,11 +73,10 @@ const Action = ({ children, ...props }) => ( * Upload * * - * @param {React.ReactNode} props.children - content of the action - * @param {object} [props] - {@link Action} props + * @param {ButtonWithoutVariantProps} props */ -const PrimaryAction = ({ children, ...props }) => ( - { children } +const PrimaryAction = ({ children, ...actionProps }) => ( + { children } ); /** @@ -84,11 +88,10 @@ const PrimaryAction = ({ children, ...props }) => ( * @example Using it with a custom text * Accept * - * @param {React.ReactNode} [props.children="confirm"] - content of the action - * @param {object} [props] - {@link Action} props + * @param {ButtonWithoutVariantProps} props */ -const Confirm = ({ children = _("Confirm"), ...props }) => ( - { children } +const Confirm = ({ children = _("Confirm"), ...actionProps }) => ( + { children } ); /** @@ -106,11 +109,10 @@ const Confirm = ({ children = _("Confirm"), ...props }) => ( * Dismiss * * - * @param {React.ReactNode} props.children - content of the action - * @param {object} [props] - {@link Action} props + * @param {ButtonWithoutVariantProps} props */ -const SecondaryAction = ({ children, ...props }) => ( - { children } +const SecondaryAction = ({ children, ...actionProps }) => ( + { children } ); /** @@ -122,11 +124,10 @@ const SecondaryAction = ({ children, ...props }) => ( * @example Using it with a custom text * Dismiss * - * @param {React.ReactNode} [props.children="Cancel"] - content of the action - * @param {object} [props] - {@link Action} props + * @param {ButtonWithoutVariantProps} props */ -const Cancel = ({ children = _("Cancel"), ...props }) => ( - { children } +const Cancel = ({ children = _("Cancel"), ...actionProps }) => ( + { children } ); /** @@ -144,11 +145,10 @@ const Cancel = ({ children = _("Cancel"), ...props }) => ( * Do not set * * - * @param {React.ReactNode} props.children - content of the action - * @param {object} [props] - {@link Action} props + * @param {ButtonWithoutVariantProps} props */ -const AncillaryAction = ({ children, ...props }) => ( - { children } +const AncillaryAction = ({ children, ...actionsProps }) => ( + { children } ); /** @@ -187,13 +187,7 @@ const AncillaryAction = ({ children, ...props }) => ( * *
* - * @param {object} props - component props - * @param {boolean} [props.isOpen=false] - whether the popup is displayed or not - * @param {boolean} [props.showClose=false] - whether the popup should include a "X" action for closing it - * @param {string} [props.variant="small"] - the popup size, based on PF/Modal `variant` prop - * @param {React.ReactNode} props.children - the popup content and actions - * @param {object} [pfModalProps] - PF/Modal props, See {@link https://www.patternfly.org/components/modal#props} - * + * @param {ModalProps} props */ const Popup = ({ isOpen = false, @@ -205,6 +199,7 @@ const Popup = ({ const [actions, content] = partition(React.Children.toArray(children), child => child.type === Actions); return ( + /** @ts-ignore */ void} [onClose] - A callback to be called when sidebar is closed. + * @property {React.ReactNode} [props.children] + * + * @param {SidebarProps} */ -export default function Sidebar ({ children, isOpen, onClose = noop }) { +export default function Sidebar ({ isOpen, onClose = noop, children }) { const asideRef = useRef(null); const closeButtonRef = useRef(null); const [addAttribute, removeAttribute] = useNodeSiblings(asideRef.current); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index ee024a4417..3b35412f43 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -39,6 +39,7 @@ export { default as InstallButton } from "./InstallButton"; export { default as IssuesDialog } from "./IssuesDialog"; export { default as SectionSkeleton } from "./SectionSkeleton"; export { default as ListSearch } from "./ListSearch"; +export { default as LoginPage } from "./LoginPage"; export { default as LogsButton } from "./LogsButton"; export { default as FileViewer } from "./FileViewer"; export { default as RowActions } from "./RowActions"; diff --git a/web/src/components/layout/Center.jsx b/web/src/components/layout/Center.jsx index 2005592682..f94d6b2c8d 100644 --- a/web/src/components/layout/Center.jsx +++ b/web/src/components/layout/Center.jsx @@ -19,6 +19,8 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React from "react"; /** @@ -42,7 +44,8 @@ import React from "react"; * - https://www.w3.org/TR/selectors-4/#relational * - https://ishadeed.com/article/css-has-parent-selector/ * - * @param {React.ReactNode} [props.children] + * @param {object} props + * @param {React.ReactNode} props.children */ const Center = ({ children }) => (
diff --git a/web/src/components/product/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.jsx index 90a902b3bd..e2ff2858e4 100644 --- a/web/src/components/product/ProductSelectionPage.jsx +++ b/web/src/components/product/ProductSelectionPage.jsx @@ -31,7 +31,7 @@ import { useInstallerClient } from "~/context/installer"; import { useProduct } from "~/context/product"; function ProductSelectionPage() { - const { manager, software } = useInstallerClient(); + const { manager, product } = useInstallerClient(); const navigate = useNavigate(); const { products, selectedProduct } = useProduct(); const [newProductId, setNewProductId] = useState(selectedProduct?.id); @@ -39,16 +39,17 @@ function ProductSelectionPage() { useEffect(() => { // TODO: display a notification in the UI to emphasizes that // selected product has changed - return software.product.onChange(() => navigate("/")); - }, [software, navigate]); + return product.onChange(() => navigate("/")); + }, [product, navigate]); const onSubmit = async (e) => { e.preventDefault(); if (newProductId !== selectedProduct?.id) { // TODO: handle errors - await software.product.select(newProductId); - manager.startProbing(); + await product.select(newProductId); + // FIXME: adapt to the new API + // manager.startProbing(); } navigate("/"); diff --git a/web/src/context/agama.jsx b/web/src/context/app.jsx similarity index 93% rename from web/src/context/agama.jsx rename to web/src/context/app.jsx index c17f6a8803..761ebd8385 100644 --- a/web/src/context/agama.jsx +++ b/web/src/context/app.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2024] SUSE LLC * * All Rights Reserved. * @@ -33,7 +33,7 @@ import { ProductProvider } from "./product"; * @param {object} props * @param {React.ReactNode} [props.children] - content to display within the provider. */ -function AgamaProviders({ children }) { +function AppProviders({ children }) { return ( @@ -47,6 +47,4 @@ function AgamaProviders({ children }) { ); } -export { - AgamaProviders -}; +export { AppProviders }; diff --git a/web/src/context/auth.jsx b/web/src/context/auth.jsx new file mode 100644 index 0000000000..4b9e2f8c59 --- /dev/null +++ b/web/src/context/auth.jsx @@ -0,0 +1,80 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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, { useCallback, useEffect, useState } from "react"; + +const AuthContext = React.createContext(null); + +/** + * Returns the authentication functions + */ +function useAuth() { + const context = React.useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within a AuthProvider"); + } + + return context; +} + +/** + * @param {object} props + * @param {React.ReactNode} [props.children] - content to display within the provider + */ +function AuthProvider({ children }) { + const [isLoggedIn, setIsLoggedIn] = useState(false); + + const login = useCallback(async (password) => { + const response = await fetch("/api/auth", { + method: "POST", + body: JSON.stringify({ password }), + headers: { "Content-Type": "application/json" }, + }); + setIsLoggedIn(response.status === 200); + }, []); + + const logout = useCallback(async () => { + await fetch("/api/auth", { + method: "DELETE", + }); + setIsLoggedIn(false); + }, []); + + useEffect(() => { + fetch("/api/auth", { + headers: { "Content-Type": "application/json" }, + }) + .then((response) => { + setIsLoggedIn(response.status === 200); + }) + .catch(() => setIsLoggedIn(false)); + }, []); + + return ( + + {children} + + ); +} + +export { AuthProvider, useAuth }; diff --git a/web/src/context/installerL10n.jsx b/web/src/context/installerL10n.jsx index 37aeeb630b..9db8512c8d 100644 --- a/web/src/context/installerL10n.jsx +++ b/web/src/context/installerL10n.jsx @@ -183,17 +183,6 @@ function reload(newLanguage) { } } -/** - * Extracts the keymap from the `setxkbmap -query` output. - * - * @param {string} output - * @returns {string|undefined} - */ -function keymapFromX(output) { - const matcher = /^layout:\s+(\S.*)$/m; - return matcher.exec(output)?.at(1); -} - /** * This provider sets the installer locale. By default, it uses the URL "lang" query parameter or * the preferred locale from the browser and synchronizes the UI and the backend locales. To @@ -258,13 +247,11 @@ function InstallerL10nProvider({ children }) { }, [storeInstallerLanguage, setLanguage]); const changeKeymap = useCallback(async (id) => { + if (!client) return; + setKeymap(id); - // write the config to file (/etc/X11/xorg.conf.d/00-keyboard.conf), - // this also sets the console keyboard! - await cockpit.spawn(["localectl", "set-x11-keymap", id]); - // set the current X11 keyboard - await cockpit.spawn(["setxkbmap", id], { environ: ["DISPLAY=:0"] }); - }, [setKeymap]); + client.l10n.setUIKeymap(id); + }, [setKeymap, client]); useEffect(() => { if (!language) changeLanguage(); @@ -278,8 +265,9 @@ function InstallerL10nProvider({ children }) { }, [client, language, backendPending, storeInstallerLanguage]); useEffect(() => { - cockpit.spawn(["setxkbmap", "-query"], { environ: ["DISPLAY=:0"] }).then(output => setKeymap(keymapFromX(output))); - }, [setKeymap]); + if (!client) return; + client.l10n.getUIKeymap().then(setKeymap); + }, [setKeymap, client]); const value = { language, changeLanguage, keymap, changeKeymap }; diff --git a/web/src/context/product.jsx b/web/src/context/product.jsx index c9506924c8..44100931fe 100644 --- a/web/src/context/product.jsx +++ b/web/src/context/product.jsx @@ -39,13 +39,13 @@ function ProductProvider({ children }) { useEffect(() => { const load = async () => { - const productManager = client.software.product; - const available = await cancellablePromise(productManager.getAll()); - const selected = await cancellablePromise(productManager.getSelected()); - const registration = await cancellablePromise(productManager.getRegistration()); + const productClient = client.product; + const available = await cancellablePromise(productClient.getAll()); + const selected = await cancellablePromise(productClient.getSelected()); + // const registration = await cancellablePromise(productManager.getRegistration()); setProducts(available); - setSelectedId(selected?.id || null); - setRegistration(registration); + setSelectedId(selected); + // setRegistration(registration); }; if (client) { @@ -53,17 +53,17 @@ function ProductProvider({ children }) { } }, [client, setProducts, setSelectedId, setRegistration, cancellablePromise]); - useEffect(() => { - if (!client) return; + // useEffect(() => { + // if (!client) return; - return client.software.product.onChange(setSelectedId); - }, [client, setSelectedId]); + // return client.software.product.onChange(setSelectedId); + // }, [client, setSelectedId]); - useEffect(() => { - if (!client) return; + // useEffect(() => { + // if (!client) return; - return client.software.product.onRegistrationChange(setRegistration); - }, [client, setRegistration]); + // return client.software.product.onRegistrationChange(setRegistration); + // }, [client, setRegistration]); const value = { products, selectedId, registration }; return {children}; diff --git a/web/src/context/root.jsx b/web/src/context/root.jsx new file mode 100644 index 0000000000..dc2f4eb170 --- /dev/null +++ b/web/src/context/root.jsx @@ -0,0 +1,41 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 from "react"; +import { AuthProvider } from "./auth"; + +/** + * Combines all application providers. + * + * @param {object} props + * @param {React.ReactNode} [props.children] - content to display within the provider. + */ +function RootProviders({ children }) { + return ( + + {children} + + ); +} + +export { RootProviders }; diff --git a/web/src/index.js b/web/src/index.js index 4a0d97ab2e..bb8a60b6e5 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -23,7 +23,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { HashRouter, Routes, Route } from "react-router-dom"; -import { AgamaProviders } from "~/context/agama"; +import { RootProviders } from "~/context/root"; /** * Import PF base styles before any JSX since components coming from PF may @@ -33,12 +33,14 @@ import "@patternfly/patternfly/patternfly-base.scss"; import App from "~/App"; import Main from "~/Main"; +import Protected from "~/Protected"; import { OverviewPage } from "~/components/overview"; import { ProductPage, ProductSelectionPage } from "~/components/product"; import { SoftwarePage } from "~/components/software"; import { ProposalPage as StoragePage, ISCSIPage, DASDPage, ZFCPPage } from "~/components/storage"; import { UsersPage } from "~/components/users"; import { L10nPage } from "~/components/l10n"; +import { LoginPage } from "./components/core"; import { NetworkPage } from "~/components/network"; /** @@ -57,26 +59,29 @@ const container = document.getElementById("root"); const root = createRoot(container); root.render( - + - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + }> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> - } /> - + ); diff --git a/web/webpack.config.js b/web/webpack.config.js index 7edbd96bff..342f4d9fad 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -104,10 +104,15 @@ module.exports = { // selfHandleResponse : true, // onProxyRes: manifests_handler, // }, + "/api/ws": { + target: agamaServer.replace(/^http/, "ws"), + ws: true, + secure: false, + }, "/api": { target: agamaServer, secure: false, - } + }, }, server: "http", // hot replacement does not support wss:// transport when running over https://,