diff --git a/Cargo.lock b/Cargo.lock index e63da4d..e82c7f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,7 +156,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.52.0", "x11rb", ] @@ -737,7 +737,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.23.0", + "darling 0.20.11", "ident_case", "prettyplease", "proc-macro2", @@ -952,7 +952,7 @@ dependencies = [ [[package]] name = "client" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "async-trait", @@ -1045,7 +1045,7 @@ dependencies = [ [[package]] name = "common" -version = "0.8.23" +version = "0.8.24" dependencies = [ "prost", "prost-build", @@ -2400,7 +2400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5113,7 +5113,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools", "log", "multimap", @@ -5719,7 +5719,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5803,7 +5803,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "search" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "common", @@ -5953,7 +5953,7 @@ dependencies = [ [[package]] name = "server" -version = "0.8.23" +version = "0.8.24" dependencies = [ "analytics-graphs", "anyhow", @@ -6202,7 +6202,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", @@ -6887,7 +6887,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7451,7 +7451,7 @@ checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" [[package]] name = "tui-canvas" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "arboard", @@ -7470,7 +7470,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", - "tui-canvas-validation-core 0.8.23", + "tui-canvas-validation-core 0.8.24", "unicode-width 0.2.2", ] @@ -7487,7 +7487,7 @@ dependencies = [ [[package]] name = "tui-canvas-validation-core" -version = "0.8.23" +version = "0.8.24" dependencies = [ "regex", "serde", @@ -7497,7 +7497,7 @@ dependencies = [ [[package]] name = "tui-pages" -version = "0.8.23" +version = "0.8.24" dependencies = [ "crossterm", "nucleo", @@ -8140,7 +8140,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/client b/client index b56c217..9d5d093 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit b56c2179dddaa0753b1af17ae99c2139948176ff +Subproject commit 9d5d09322f64f0115cd442a1b09857116fd33356 diff --git a/graphs/README.md b/graphs/README.md index 36a321f..2915d26 100644 --- a/graphs/README.md +++ b/graphs/README.md @@ -1,8 +1,12 @@ -# Analytics graphs +# Web admin and analytics -A small Rust web server that logs in through `AuthService.Login`, forwards SQL -queries to `AnalyticsService.ExecuteAnalyticsQuery`, and displays the streamed -result with HTMX, Alpine.js, and ECharts. +An Axum SSR/HTMX web frontend for the existing komp_ac gRPC backend. It provides +the browser version of the client admin panel and the ECharts analytics view. +The browser never connects to gRPC directly: Axum authenticates with an +HTTP-only cookie, creates typed Tonic requests, and returns full HTML pages or +HTMX fragments. + +The backend remains the authority; this crate does not add another HTTP API to it. ## Run @@ -13,8 +17,9 @@ binary. From the parent workspace, start the server normally: cargo run -p server -- server ``` -Open to log in, then use the analytics page at -. The access token is kept in an HTTP-only cookie. The default gRPC endpoint is +Open to log in. The admin panel is at + and analytics remains at . +The access token is kept in an HTTP-only cookie. The default gRPC endpoint is `http://[::1]:50051`. Both addresses can be changed: ```sh @@ -24,6 +29,25 @@ cargo run -p server -- server ``` The first SQL result column is used for category labels. Bar and line charts use +## Admin panel + +The admin panel mirrors the client workflow with browser-oriented pages: + +- profile, table, and physical-column browsing; +- table creation, including indexes, links, money settings, and row labels; +- Steel table-script creation; +- field validations, reusable global rules, validation sets, and set application; +- browser CSV import with live schema validation and chunked bulk insertion; +- CSV downloads for one or multiple tables; +- login, role checks, logout, and navigation to analytics. + +CSV files use the same column-header convention as the client. Multi-table CSV +files include a table-name header row before the column-name row. Browser files +are read locally and submitted to Axum; the backend is accessed only through the +existing `TablesData` gRPC service. + +## Analytics + all remaining columns as numeric series, pie uses the second column, and scatter uses the first two columns. Table view displays every returned value. diff --git a/graphs/src/lib.rs b/graphs/src/lib.rs index ffbb018..ad4baff 100644 --- a/graphs/src/lib.rs +++ b/graphs/src/lib.rs @@ -7,6 +7,8 @@ use axum::{ response::{Html, IntoResponse, Response}, routing::{get, post}, }; +mod pages; +mod services; mod analytics { include!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -33,6 +35,30 @@ mod definitions { "/../common/src/proto/komp_ac.table_definition.rs" )); } + pub mod table_structure { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../common/src/proto/komp_ac.table_structure.rs" + )); + } + pub mod table_script { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../common/src/proto/komp_ac.table_script.rs" + )); + } + pub mod table_validation { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../common/src/proto/komp_ac.table_validation.rs" + )); + } + pub mod tables_data { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../common/src/proto/komp_ac.tables_data.rs" + )); + } } use analytics::{ @@ -44,6 +70,10 @@ use auth::{LoginRequest, auth_service_client::AuthServiceClient}; use definitions::{ common::Empty, table_definition::table_definition_client::TableDefinitionClient, + table_script::table_script_client::TableScriptClient, + table_structure::table_structure_service_client::TableStructureServiceClient, + table_validation::table_validation_service_client::TableValidationServiceClient, + tables_data::tables_data_client::TablesDataClient, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -53,10 +83,14 @@ const INDEX_HTML: &str = include_str!("../static/index.html"); const LOGIN_HTML: &str = include_str!("../static/login.html"); #[derive(Clone)] -struct AppState { +pub(crate) struct AppState { analytics: AnalyticsServiceClient, auth: AuthServiceClient, definitions: TableDefinitionClient, + scripts: TableScriptClient, + structures: TableStructureServiceClient, + validations: TableValidationServiceClient, + tables_data: TablesDataClient, } #[derive(Deserialize)] @@ -124,7 +158,11 @@ pub async fn serve() -> Result<(), Box> { let state = AppState { analytics: AnalyticsServiceClient::new(channel.clone()), auth: AuthServiceClient::new(channel.clone()), - definitions: TableDefinitionClient::new(channel), + definitions: TableDefinitionClient::new(channel.clone()), + scripts: TableScriptClient::new(channel.clone()), + validations: TableValidationServiceClient::new(channel.clone()), + tables_data: TablesDataClient::new(channel.clone()), + structures: TableStructureServiceClient::new(channel), }; let app = Router::new() @@ -133,6 +171,11 @@ pub async fn serve() -> Result<(), Box> { .route("/api/profiles", get(load_profiles)) .route("/api/catalog", post(load_catalog)) .route("/api/query", post(run_query)) + .merge(pages::admin::admin::router()) + .merge(pages::add_table::router()) + .merge(pages::add_logic::router()) + .merge(pages::add_validation::router()) + .merge(pages::import_export::router()) .with_state(state); let listener = tokio::net::TcpListener::bind(listen_address).await?; @@ -176,7 +219,7 @@ async fn login(State(state): State, Form(input): Form) -> response.headers_mut().insert(header::SET_COOKIE, cookie); response .headers_mut() - .insert("hx-redirect", HeaderValue::from_static("/")); + .insert("hx-redirect", HeaderValue::from_static("/admin")); response } Err(error) => error_fragment(error.message()).into_response(), diff --git a/graphs/src/pages/add_logic/loader.rs b/graphs/src/pages/add_logic/loader.rs new file mode 100644 index 0000000..3667e18 --- /dev/null +++ b/graphs/src/pages/add_logic/loader.rs @@ -0,0 +1,59 @@ +use axum::http::HeaderMap; + +use crate::{ + AppState, + auth::GetAuthorizationRequest, + definitions::common::Empty, + services::authenticated_request, +}; + +use super::state::{AddLogicPageState, CreateLogicForm, TableOption}; + +pub(crate) async fn load_page( + state: AppState, + headers: &HeaderMap, + form: CreateLogicForm, + error: Option, +) -> Result { + let request = authenticated_request(headers, GetAuthorizationRequest {}) + .map_err(|_| LoadError::Unauthenticated)?; + let mut auth = state.auth; + let authorization = auth + .get_authorization(request) + .await + .map_err(|error| match error.code() { + tonic::Code::Unauthenticated => LoadError::Unauthenticated, + _ => LoadError::Backend(error.message().to_string()), + })? + .into_inner(); + if authorization.role != "admin" { + return Err(LoadError::Forbidden); + } + + let mut definitions = state.definitions; + let tree = definitions + .get_profile_tree( + authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + let tables = tree + .profiles + .into_iter() + .flat_map(|profile| { + profile.tables.into_iter().map(move |table| TableOption { + id: table.id, + profile_name: profile.name.clone(), + table_name: table.name, + }) + }) + .collect(); + Ok(AddLogicPageState { tables, form, error }) +} + +pub(crate) enum LoadError { + Unauthenticated, + Forbidden, + Backend(String), +} diff --git a/graphs/src/pages/add_logic/logic.rs b/graphs/src/pages/add_logic/logic.rs new file mode 100644 index 0000000..386ca28 --- /dev/null +++ b/graphs/src/pages/add_logic/logic.rs @@ -0,0 +1,78 @@ +use axum::{ + Form, + extract::State, + http::{HeaderMap, StatusCode}, + response::{Html, IntoResponse, Redirect, Response}, +}; + +use crate::{AppState, services::authenticated_request}; + +use super::{ + loader::{LoadError, load_page}, + state::CreateLogicForm, + ui, +}; + +pub(crate) async fn new_logic_page( + State(state): State, + headers: HeaderMap, +) -> Response { + render_loaded(load_page(state, &headers, CreateLogicForm::default(), None).await) +} + +pub(crate) async fn create_logic( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let submitted = form.clone(); + if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), None).await { + return render_loaded(Err(error)); + } + if headers + .get("sec-fetch-site") + .is_some_and(|value| value == "cross-site") + { + return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); + } + let request = match form.into_request() { + Ok(request) => request, + Err(message) => { + return render_loaded(load_page(state, &headers, submitted, Some(message)).await); + } + }; + let mut scripts = state.scripts; + let request = match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }; + match scripts.post_table_script(request).await { + Ok(response) => Html(ui::render_success( + response.get_ref().id, + &response.get_ref().warnings, + )) + .into_response(), + Err(error) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_submission_error(error.message())), + ) + .into_response(), + } +} + +fn render_loaded(result: Result) -> Response { + match result { + Ok(page) => Html(ui::render_page(&page)).into_response(), + Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), + Err(LoadError::Forbidden) => ( + StatusCode::FORBIDDEN, + Html(ui::render_submission_error("Administrator access is required.")), + ) + .into_response(), + Err(LoadError::Backend(message)) => ( + StatusCode::BAD_GATEWAY, + Html(ui::render_submission_error(&message)), + ) + .into_response(), + } +} diff --git a/graphs/src/pages/add_logic/mod.rs b/graphs/src/pages/add_logic/mod.rs new file mode 100644 index 0000000..448f268 --- /dev/null +++ b/graphs/src/pages/add_logic/mod.rs @@ -0,0 +1,17 @@ +mod loader; +mod logic; +mod state; +mod ui; + +use axum::{ + Router, + routing::{get, post}, +}; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new() + .route("/admin/logic/new", get(logic::new_logic_page)) + .route("/admin/logic", post(logic::create_logic)) +} diff --git a/graphs/src/pages/add_logic/state.rs b/graphs/src/pages/add_logic/state.rs new file mode 100644 index 0000000..ec52b98 --- /dev/null +++ b/graphs/src/pages/add_logic/state.rs @@ -0,0 +1,52 @@ +use crate::definitions::table_script::PostTableScriptRequest; + +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct CreateLogicForm { + #[serde(default)] + pub table_definition_id: i64, + #[serde(default)] + pub logic_name: String, + #[serde(default)] + pub target_column: String, + #[serde(default)] + pub script: String, + #[serde(default)] + pub description: String, +} + +pub(crate) struct AddLogicPageState { + pub tables: Vec, + pub form: CreateLogicForm, + pub error: Option, +} + +pub(crate) struct TableOption { + pub id: i64, + pub profile_name: String, + pub table_name: String, +} + +impl CreateLogicForm { + pub(crate) fn into_request(self) -> Result { + if self.table_definition_id <= 0 { + return Err("Select a target table.".to_string()); + } + let target_column = self.target_column.trim().to_string(); + if target_column.is_empty() { + return Err("Enter the target column.".to_string()); + } + let script = self.script.trim().to_string(); + if script.is_empty() { + return Err("Enter a Steel expression.".to_string()); + } + if !script.starts_with('(') { + return Err("The Steel expression must start with `(`.".to_string()); + } + Ok(PostTableScriptRequest { + table_definition_id: self.table_definition_id, + target_column, + script, + description: self.description.trim().to_string(), + }) + } +} diff --git a/graphs/src/pages/add_logic/ui.rs b/graphs/src/pages/add_logic/ui.rs new file mode 100644 index 0000000..db761cc --- /dev/null +++ b/graphs/src/pages/add_logic/ui.rs @@ -0,0 +1,45 @@ +use super::state::AddLogicPageState; + +const ADMIN_CSS: &str = include_str!("../../../static/admin.css"); + +pub(crate) fn render_page(page: &AddLogicPageState) -> String { + let tables = page + .tables + .iter() + .map(|table| { + format!( + "", + table.id, + if page.form.table_definition_id == table.id { "selected" } else { "" }, + crate::escape_html(&table.profile_name), + crate::escape_html(&table.table_name), + ) + }) + .collect::(); + let error = page.error.as_deref().map(render_submission_error).unwrap_or_default(); + format!( + "Add logic
Komp Accounting
← Admin panel

Computed column

Add logic

Create or update a Steel script through the existing table-script service.

{error}
Cancel
", + crate::escape_html(&page.form.logic_name), + crate::escape_html(&page.form.target_column), + crate::escape_html(&page.form.script), + crate::escape_html(&page.form.description), + ) +} + +pub(crate) fn render_submission_error(message: &str) -> String { + format!( + "
Could not save the logic

{}

", + crate::escape_html(message), + ) +} + +pub(crate) fn render_success(id: i64, warnings: &str) -> String { + let warnings = if warnings.trim().is_empty() { + String::new() + } else { + format!("

Warnings: {}

", crate::escape_html(warnings)) + }; + format!( + "
Logic saved

Script #{id} was saved.

{warnings}
" + ) +} diff --git a/graphs/src/pages/add_table/loader.rs b/graphs/src/pages/add_table/loader.rs new file mode 100644 index 0000000..25319c6 --- /dev/null +++ b/graphs/src/pages/add_table/loader.rs @@ -0,0 +1,52 @@ +use axum::http::HeaderMap; + +use crate::{ + AppState, + auth::GetAuthorizationRequest, + definitions::common::Empty, + services::authenticated_request, +}; + +use super::state::{AddTablePageState, CreateTableForm}; + +pub(crate) async fn load_page( + state: AppState, + headers: &HeaderMap, + form: CreateTableForm, + error: Option, +) -> Result { + let authorization_request = + authenticated_request(headers, GetAuthorizationRequest {}).map_err(|_| LoadError::Unauthenticated)?; + let mut auth = state.auth; + let authorization = auth + .get_authorization(authorization_request) + .await + .map_err(|error| match error.code() { + tonic::Code::Unauthenticated => LoadError::Unauthenticated, + _ => LoadError::Backend(error.message().to_string()), + })? + .into_inner(); + if authorization.role != "admin" { + return Err(LoadError::Forbidden); + } + + let mut definitions = state.definitions; + let tree = definitions + .get_profile_tree( + authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + Ok(AddTablePageState { + profiles: tree.profiles.into_iter().map(|profile| profile.name).collect(), + form, + error, + }) +} + +pub(crate) enum LoadError { + Unauthenticated, + Forbidden, + Backend(String), +} diff --git a/graphs/src/pages/add_table/logic.rs b/graphs/src/pages/add_table/logic.rs new file mode 100644 index 0000000..9ed5b9c --- /dev/null +++ b/graphs/src/pages/add_table/logic.rs @@ -0,0 +1,98 @@ +use axum::{ + Form, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{Html, IntoResponse, Redirect, Response}, +}; + +use crate::{AppState, services::authenticated_request}; + +use super::{ + loader::{LoadError, load_page}, + state::CreateTableForm, + ui, +}; + +pub(crate) async fn new_table_page( + State(state): State, + headers: HeaderMap, +) -> Response { + render_loaded(load_page(state, &headers, CreateTableForm::default(), None).await) +} + +pub(crate) async fn create_table( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let submitted_form = form.clone(); + if let Err(error) = load_page(state.clone(), &headers, form.clone(), None).await { + return render_loaded(Err(error)); + } + if headers + .get("sec-fetch-site") + .is_some_and(|value| value == "cross-site") + { + return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); + } + let request = match form.into_request() { + Ok(request) => request, + Err(message) => { + return render_loaded( + load_page(state, &headers, submitted_form, Some(message)).await, + ); + } + }; + let profile_name = request.profile_name.clone(); + let mut definitions = state.definitions; + let request = match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }; + match definitions.post_table_definition(request).await { + Ok(response) if response.get_ref().success => { + let location = format!("/admin?profile={profile_name}"); + let Ok(location) = HeaderValue::try_from(location) else { + return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(); + }; + let mut response = StatusCode::SEE_OTHER.into_response(); + response.headers_mut().insert(header::LOCATION, location.clone()); + response.headers_mut().insert("hx-redirect", location); + response + } + Ok(response) => { + let detail = if response.get_ref().sql.is_empty() { + "The backend did not create the table.".to_string() + } else { + response.get_ref().sql.clone() + }; + ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_submission_error(&detail)), + ) + .into_response() + } + Err(error) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_submission_error(error.message())), + ) + .into_response(), + } +} + +fn render_loaded(result: Result) -> Response { + match result { + Ok(page) => Html(ui::render_page(&page)).into_response(), + Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), + Err(LoadError::Forbidden) => ( + StatusCode::FORBIDDEN, + Html(ui::render_submission_error("Administrator access is required.")), + ) + .into_response(), + Err(LoadError::Backend(message)) => ( + StatusCode::BAD_GATEWAY, + Html(ui::render_submission_error(&message)), + ) + .into_response(), + } +} diff --git a/graphs/src/pages/add_table/mod.rs b/graphs/src/pages/add_table/mod.rs new file mode 100644 index 0000000..8a1e841 --- /dev/null +++ b/graphs/src/pages/add_table/mod.rs @@ -0,0 +1,17 @@ +mod loader; +mod logic; +mod state; +mod ui; + +use axum::{ + Router, + routing::{get, post}, +}; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new() + .route("/admin/tables/new", get(logic::new_table_page)) + .route("/admin/tables", post(logic::create_table)) +} diff --git a/graphs/src/pages/add_table/state.rs b/graphs/src/pages/add_table/state.rs new file mode 100644 index 0000000..8a23f8f --- /dev/null +++ b/graphs/src/pages/add_table/state.rs @@ -0,0 +1,168 @@ +use crate::definitions::table_definition::{ + ColumnDefinition, MoneyRounding, PostTableDefinitionRequest, TableLink, +}; + +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct CreateTableForm { + #[serde(default)] + pub profile_name: String, + #[serde(default)] + pub table_name: String, + #[serde(default)] + pub columns: String, + #[serde(default)] + pub indexed_columns: String, + #[serde(default)] + pub required_links: String, + #[serde(default)] + pub optional_links: String, + #[serde(default)] + pub base_currency: String, + #[serde(default = "default_row_display_column")] + pub row_display_column: String, +} + +pub(crate) struct AddTablePageState { + pub profiles: Vec, + pub form: CreateTableForm, + pub error: Option, +} + +impl CreateTableForm { + pub(crate) fn into_request(self) -> Result { + let profile_name = self.profile_name.trim().to_string(); + let table_name = self.table_name.trim().to_string(); + if profile_name.is_empty() { + return Err("Select a profile.".to_string()); + } + if table_name.is_empty() { + return Err("Enter a table name.".to_string()); + } + + let indexed_columns = comma_separated(&self.indexed_columns); + let mut columns = Vec::new(); + let mut inline_indexes = Vec::new(); + for (index, line) in self.columns.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let mut parts = line.splitn(3, ':'); + let name = parts.next().unwrap_or_default().trim(); + let field_type = parts.next().unwrap_or_default().trim(); + let flags = parts.next().unwrap_or_default(); + if name.is_empty() || field_type.is_empty() { + return Err(format!( + "Column line {} must use `name: type`.", + index + 1 + )); + } + let flags = flags + .split(',') + .map(str::trim) + .filter(|flag| !flag.is_empty()) + .collect::>(); + if flags.contains(&"indexed") { + inline_indexes.push(name.to_string()); + } + let rounding = if flags.contains(&"half-up") { + MoneyRounding::HalfUp + } else { + MoneyRounding::None + }; + columns.push(ColumnDefinition { + name: name.to_string(), + field_type: field_type.to_string(), + rounding: rounding.into(), + recompute_on_dependency_change: flags.contains(&"recompute"), + }); + } + if columns.is_empty() { + return Err("Add at least one column.".to_string()); + } + + let mut indexes = indexed_columns; + for name in inline_indexes { + if !indexes.contains(&name) { + indexes.push(name); + } + } + + let mut links = comma_separated(&self.required_links) + .into_iter() + .map(|linked_table_name| TableLink { + linked_table_name, + required: true, + }) + .collect::>(); + links.extend( + comma_separated(&self.optional_links) + .into_iter() + .map(|linked_table_name| TableLink { + linked_table_name, + required: false, + }), + ); + + let has_money = columns + .iter() + .any(|column| column.field_type.eq_ignore_ascii_case("money")); + let base_currency = self.base_currency.trim().to_ascii_uppercase(); + if has_money && base_currency.is_empty() { + return Err("A base currency is required when a MONEY column is used.".to_string()); + } + + Ok(PostTableDefinitionRequest { + table_name, + links, + columns, + indexes, + profile_name, + base_currency: if has_money { base_currency } else { String::new() }, + row_display_column: { + let value = self.row_display_column.trim(); + if value.is_empty() { "id".to_string() } else { value.to_string() } + }, + }) + } +} + +fn comma_separated(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +fn default_row_display_column() -> String { + "id".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_columns_indexes_links_and_money_options() { + let request = CreateTableForm { + profile_name: "accounting".into(), + table_name: "invoice".into(), + columns: "number: text:indexed\namount: money:half-up,recompute".into(), + required_links: "customer".into(), + base_currency: "eur".into(), + row_display_column: "number".into(), + ..Default::default() + } + .into_request() + .unwrap(); + + assert_eq!(request.indexes, vec!["number"]); + assert_eq!(request.links[0].linked_table_name, "customer"); + assert!(request.links[0].required); + assert_eq!(request.base_currency, "EUR"); + assert_eq!(request.row_display_column, "number"); + assert!(request.columns[1].recompute_on_dependency_change); + } +} diff --git a/graphs/src/pages/add_table/ui.rs b/graphs/src/pages/add_table/ui.rs new file mode 100644 index 0000000..36b6704 --- /dev/null +++ b/graphs/src/pages/add_table/ui.rs @@ -0,0 +1,40 @@ +use super::state::AddTablePageState; + +const ADMIN_CSS: &str = include_str!("../../../static/admin.css"); + +pub(crate) fn render_page(page: &AddTablePageState) -> String { + let options = page + .profiles + .iter() + .map(|profile| { + format!( + "", + crate::escape_html(profile), + if page.form.profile_name == *profile { "selected" } else { "" }, + crate::escape_html(profile), + ) + }) + .collect::(); + let error = page + .error + .as_deref() + .map(render_submission_error) + .unwrap_or_default(); + format!( + "Add table
Komp Accounting
← Admin panel

Table definition

Add table

Create a table through the existing gRPC table-definition service.

{error}
Cancel
", + crate::escape_html(&page.form.table_name), + crate::escape_html(&page.form.columns), + crate::escape_html(&page.form.indexed_columns), + crate::escape_html(&page.form.base_currency), + crate::escape_html(&page.form.required_links), + crate::escape_html(&page.form.optional_links), + crate::escape_html(&page.form.row_display_column), + ) +} + +pub(crate) fn render_submission_error(message: &str) -> String { + format!( + "
Could not create the table

{}

", + crate::escape_html(message), + ) +} diff --git a/graphs/src/pages/add_validation/loader.rs b/graphs/src/pages/add_validation/loader.rs new file mode 100644 index 0000000..03fc6f7 --- /dev/null +++ b/graphs/src/pages/add_validation/loader.rs @@ -0,0 +1,62 @@ +use axum::http::HeaderMap; + +use crate::{ + AppState, + auth::GetAuthorizationRequest, + definitions::common::Empty, + services::authenticated_request, +}; + +use super::state::{ValidationForm, ValidationPageState, ValidationSetForm}; + +pub(crate) async fn load_page( + state: AppState, + headers: &HeaderMap, + form: ValidationForm, + reusable_rule: bool, + error: Option, +) -> Result { + let request = authenticated_request(headers, GetAuthorizationRequest {}) + .map_err(|_| LoadError::Unauthenticated)?; + let mut auth = state.auth; + let authorization = auth + .get_authorization(request) + .await + .map_err(|error| match error.code() { + tonic::Code::Unauthenticated => LoadError::Unauthenticated, + _ => LoadError::Backend(error.message().to_string()), + })? + .into_inner(); + if authorization.role != "admin" { + return Err(LoadError::Forbidden); + } + let mut definitions = state.definitions; + let tree = definitions + .get_profile_tree(authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + let profiles = tree.profiles.iter().map(|profile| profile.name.clone()).collect(); + let tables = tree + .profiles + .iter() + .flat_map(|profile| profile.tables.iter().map(|table| table.name.clone())) + .collect(); + Ok(ValidationPageState { profiles, tables, form, reusable_rule, error }) +} + +pub(crate) async fn load_set_page( + state: AppState, + headers: &HeaderMap, + form: ValidationSetForm, + error: Option, +) -> Result<(ValidationSetForm, Option), LoadError> { + let _ = load_page(state, headers, ValidationForm::default(), true, None).await?; + Ok((form, error)) +} + +pub(crate) enum LoadError { + Unauthenticated, + Forbidden, + Backend(String), +} diff --git a/graphs/src/pages/add_validation/logic.rs b/graphs/src/pages/add_validation/logic.rs new file mode 100644 index 0000000..4c7bb6f --- /dev/null +++ b/graphs/src/pages/add_validation/logic.rs @@ -0,0 +1,142 @@ +use axum::{ + Form, + extract::State, + http::{HeaderMap, StatusCode}, + response::{Html, IntoResponse, Redirect, Response}, +}; + +use crate::{AppState, services::authenticated_request}; + +use super::{ + loader::{LoadError, load_page, load_set_page}, + state::{ValidationForm, ValidationSetForm}, + ui, +}; + +pub(crate) async fn new_field_validation_page(State(state): State, headers: HeaderMap) -> Response { + render_loaded(load_page(state, &headers, ValidationForm::default(), false, None).await) +} + +pub(crate) async fn new_validation_rule_page(State(state): State, headers: HeaderMap) -> Response { + render_loaded(load_page(state, &headers, ValidationForm::default(), true, None).await) +} + +pub(crate) async fn new_validation_set_page(State(state): State, headers: HeaderMap) -> Response { + render_set_loaded(load_set_page(state, &headers, ValidationSetForm::default(), None).await) +} + +pub(crate) async fn save_field_validation( + State(state): State, headers: HeaderMap, Form(form): Form, +) -> Response { + let submitted = form.clone(); + if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), false, None).await { + return render_loaded(Err(error)); + } + if cross_site(&headers) { + return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); + } + if !form.validation_set_name.trim().is_empty() { + let request = match form.apply_set_request() { + Ok(request) => request, + Err(message) => return render_loaded(load_page(state, &headers, submitted, false, Some(message)).await), + }; + let mut validations = state.validations; + let request = match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }; + return match validations.apply_validation_set(request).await { + Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(), + Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(), + Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(), + }; + } + let request = match form.field_request() { + Ok(request) => request, + Err(message) => return render_loaded(load_page(state, &headers, submitted, false, Some(message)).await), + }; + let mut validations = state.validations; + let request = match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }; + match validations.update_field_validation(request).await { + Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(), + Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(), + Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(), + } +} + +pub(crate) async fn save_validation_rule( + State(state): State, headers: HeaderMap, Form(form): Form, +) -> Response { + let submitted = form.clone(); + if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), true, None).await { + return render_loaded(Err(error)); + } + if cross_site(&headers) { + return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); + } + let request = match form.rule_request() { + Ok(request) => request, + Err(message) => return render_loaded(load_page(state, &headers, submitted, true, Some(message)).await), + }; + let mut validations = state.validations; + let request = match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }; + match validations.upsert_validation_rule(request).await { + Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(), + Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(), + Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(), + } +} + +pub(crate) async fn save_validation_set( + State(state): State, headers: HeaderMap, Form(form): Form, +) -> Response { + let submitted = form.clone(); + if let Err(error) = load_set_page(state.clone(), &headers, submitted.clone(), None).await { + return render_set_loaded(Err(error)); + } + if cross_site(&headers) { + return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); + } + let request = match form.request() { + Ok(request) => request, + Err(message) => return render_set_loaded(load_set_page(state, &headers, submitted, Some(message)).await), + }; + let mut validations = state.validations; + let request = match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }; + match validations.upsert_validation_set(request).await { + Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(), + Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(), + Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(), + } +} + +fn render_set_loaded(result: Result<(ValidationSetForm, Option), LoadError>) -> Response { + match result { + Ok((form, error)) => Html(ui::render_set_page(&form, error.as_deref())).into_response(), + Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), + Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(), + Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(), + } +} + +fn render_loaded(result: Result) -> Response { + match result { + Ok(page) => Html(ui::render_page(&page)).into_response(), + Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), + Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(), + Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(), + } +} + +fn cross_site(headers: &HeaderMap) -> bool { + headers.get("sec-fetch-site").is_some_and(|value| value == "cross-site") +} diff --git a/graphs/src/pages/add_validation/mod.rs b/graphs/src/pages/add_validation/mod.rs new file mode 100644 index 0000000..6194888 --- /dev/null +++ b/graphs/src/pages/add_validation/mod.rs @@ -0,0 +1,27 @@ +mod loader; +mod logic; +mod state; +mod ui; + +use axum::{ + Router, + routing::{get, post}, +}; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new() + .route("/admin/validation/new", get(logic::new_field_validation_page)) + .route( + "/admin/validation/rules/new", + get(logic::new_validation_rule_page), + ) + .route( + "/admin/validation/sets/new", + get(logic::new_validation_set_page), + ) + .route("/admin/validation", post(logic::save_field_validation)) + .route("/admin/validation/rules", post(logic::save_validation_rule)) + .route("/admin/validation/sets", post(logic::save_validation_set)) +} diff --git a/graphs/src/pages/add_validation/state.rs b/graphs/src/pages/add_validation/state.rs new file mode 100644 index 0000000..cf9a21b --- /dev/null +++ b/graphs/src/pages/add_validation/state.rs @@ -0,0 +1,273 @@ +use crate::definitions::table_validation::{ + AllowedValues, ApplyValidationSetRequest, CharacterConstraint, CharacterLimits, DisplayMask, + FieldValidation, PatternPosition, PatternRule, PatternRules, UpdateFieldValidationRequest, + UpsertValidationRuleRequest, UpsertValidationSetRequest, ValidationRuleDefinition, + ValidationSetDefinition, ValidationSetRuleItem, validation_set_rule_item, +}; + +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct ValidationForm { + #[serde(default)] + pub profile_name: String, + #[serde(default)] + pub table_name: String, + #[serde(default)] + pub data_key: String, + #[serde(default)] + pub rule_name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub minimum: String, + #[serde(default)] + pub maximum: String, + #[serde(default)] + pub warn_at: String, + #[serde(default)] + pub count_mode: String, + #[serde(default)] + pub allowed_values: String, + #[serde(default)] + pub allow_empty: bool, + #[serde(default)] + pub case_insensitive: bool, + #[serde(default)] + pub mask_pattern: String, + #[serde(default)] + pub mask_input_char: String, + #[serde(default)] + pub mask_template_char: String, + #[serde(default)] + pub external_validation_enabled: bool, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub locked: bool, + + #[serde(default)] + pub pattern_rules: String, + #[serde(default)] + pub pattern_description: String, + #[serde(default)] + pub validation_set_name: String, +} +pub(crate) struct ValidationPageState { + pub profiles: Vec, + pub tables: Vec, + pub form: ValidationForm, + pub reusable_rule: bool, + pub error: Option, +} + +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct ValidationSetForm { + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub global_rules: String, +} + +impl ValidationSetForm { + pub(crate) fn request(&self) -> Result { + let name = required(&self.name, "set name")?; + let rules = comma_separated(&self.global_rules); + if rules.is_empty() { + return Err("Enter at least one reusable rule name.".to_string()); + } + let rule_items = rules.into_iter().enumerate().map(|(position, rule_name)| ValidationSetRuleItem { + position: position as i32, + name: Some(rule_name.clone()), + description: None, + source: Some(validation_set_rule_item::Source::GlobalRuleName(rule_name)), + }).collect(); + Ok(UpsertValidationSetRequest { + profile_name: String::new(), + set: Some(ValidationSetDefinition { + name, + description: nonempty(&self.description), + rule_items, + resolved_validation: None, + }), + }) + } +} + +impl ValidationForm { + pub(crate) fn field_request(&self) -> Result { + let profile_name = required(&self.profile_name, "profile")?; + let table_name = required(&self.table_name, "table")?; + let data_key = required(&self.data_key, "column")?; + Ok(UpdateFieldValidationRequest { + profile_name, + table_name, + data_key: data_key.clone(), + validation: Some(self.validation(data_key)?), + }) + } + + pub(crate) fn rule_request(&self) -> Result { + let name = required(&self.rule_name, "rule name")?; + Ok(UpsertValidationRuleRequest { + profile_name: String::new(), + rule: Some(ValidationRuleDefinition { + id: None, + name, + description: nonempty(&self.description), + validation: Some(self.validation(String::new())?), + }), + }) + } + + pub(crate) fn apply_set_request(&self) -> Result { + Ok(ApplyValidationSetRequest { + profile_name: required(&self.profile_name, "profile")?, + table_name: required(&self.table_name, "table")?, + data_key: required(&self.data_key, "column")?, + set_name: required(&self.validation_set_name, "validation set name")?, + }) + } + + fn validation(&self, data_key: String) -> Result { + let minimum = parse_u32(&self.minimum, "Minimum")?.unwrap_or(0); + let maximum = parse_u32(&self.maximum, "Maximum")?.unwrap_or(0); + if maximum > 0 && minimum > maximum { + return Err("Minimum cannot be greater than maximum.".to_string()); + } + let warn_at = parse_u32(&self.warn_at, "Warning threshold")?; + let count_mode = match self.count_mode.as_str() { + "bytes" => 2, + "display-width" => 3, + _ => 1, + }; + let limits = (minimum > 0 || maximum > 0 || warn_at.is_some()).then_some(CharacterLimits { + min: minimum, + max: maximum, + warn_at, + count_mode, + }); + let allowed = comma_separated(&self.allowed_values); + let allowed_values = (!allowed.is_empty()).then_some(AllowedValues { + values: allowed, + allow_empty: self.allow_empty, + case_insensitive: self.case_insensitive, + }); + let mask_pattern = self.mask_pattern.trim(); + let mask = (!mask_pattern.is_empty()).then_some(DisplayMask { + pattern: mask_pattern.to_string(), + input_char: if self.mask_input_char.is_empty() { + "#".to_string() + } else { + self.mask_input_char.clone() + }, + template_char: nonempty(&self.mask_template_char), + }); + let pattern = parse_pattern_rules(&self.pattern_rules, &self.pattern_description)?; + Ok(FieldValidation { + data_key, + limits, + pattern, + allowed_values, + external_validation_enabled: self.external_validation_enabled, + mask, + required: self.required, + locked: self.locked, + }) + } +} + +fn required(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + Err(format!("Enter a {label}.")) + } else { + Ok(value.to_string()) + } +} + +fn parse_u32(value: &str, label: &str) -> Result, String> { + let value = value.trim(); + if value.is_empty() { + Ok(None) + } else { + value.parse().map(Some).map_err(|_| format!("{label} must be a non-negative integer.")) + } +} + +fn comma_separated(value: &str) -> Vec { + value.split(',').map(str::trim).filter(|v| !v.is_empty()).map(str::to_string).collect() +} + +fn nonempty(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn parse_pattern_rules(value: &str, description: &str) -> Result, String> { + let mut rules = Vec::new(); + for (index, line) in value.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { continue; } + let Some((position, constraint)) = line.split_once('|') else { + return Err(format!("Pattern line {} must use `position | constraint`.", index + 1)); + }; + rules.push(PatternRule { + position: Some(parse_position(position.trim())?), + constraint: Some(parse_constraint(constraint.trim())?), + }); + } + Ok((!rules.is_empty()).then_some(PatternRules { rules, description: nonempty(description) })) +} + +fn parse_position(value: &str) -> Result { + if let Some(start) = value.strip_suffix('+') { + return Ok(PatternPosition { kind: 3, start: parse_position_number(start)?, ..Default::default() }); + } + if value.contains(',') { + let positions = value.split(',').map(parse_position_number).collect::, _>>()?; + return Ok(PatternPosition { kind: 4, positions, ..Default::default() }); + } + if let Some((start, end)) = value.split_once('-') { + let start = parse_position_number(start)?; + let end = parse_position_number(end)?; + if start > end { return Err("Pattern range start cannot exceed its end.".to_string()); } + return Ok(PatternPosition { kind: 2, start, end, ..Default::default() }); + } + Ok(PatternPosition { kind: 1, single: parse_position_number(value)?, ..Default::default() }) +} + +fn parse_position_number(value: &str) -> Result { + value.trim().parse().map_err(|_| format!("Invalid pattern position '{}'.", value.trim())) +} + +fn parse_constraint(value: &str) -> Result { + let normalized = value.to_ascii_lowercase(); + match normalized.as_str() { + "alphabetic" => Ok(CharacterConstraint { kind: 1, ..Default::default() }), + "numeric" => Ok(CharacterConstraint { kind: 2, ..Default::default() }), + "alphanumeric" => Ok(CharacterConstraint { kind: 3, ..Default::default() }), + _ if normalized.starts_with("exact=") => Ok(CharacterConstraint { kind: 4, exact: Some(value[6..].to_string()), ..Default::default() }), + _ if normalized.starts_with("one-of=") => Ok(CharacterConstraint { + kind: 5, + one_of: value[7..].split(',').map(str::trim).filter(|item| !item.is_empty()).map(str::to_string).collect(), + ..Default::default() + }), + _ if normalized.starts_with("regex=") => Ok(CharacterConstraint { kind: 6, regex: Some(value[6..].to_string()), ..Default::default() }), + _ => Err(format!("Unknown character constraint '{value}'.")), + } +} + +#[cfg(test)] +mod pattern_tests { + use super::*; + + #[test] + fn parses_position_and_constraint_rules() { + let rules = parse_pattern_rules("0-3 | alphabetic\n4,5 | one-of=-,+\n6+ | regex=[0-9]", "code").unwrap().unwrap(); + assert_eq!(rules.rules.len(), 3); + assert_eq!(rules.rules[0].position.as_ref().unwrap().kind, 2); + assert_eq!(rules.rules[1].constraint.as_ref().unwrap().kind, 5); + assert_eq!(rules.rules[2].position.as_ref().unwrap().kind, 3); + } +} diff --git a/graphs/src/pages/add_validation/ui.rs b/graphs/src/pages/add_validation/ui.rs new file mode 100644 index 0000000..e74d56f --- /dev/null +++ b/graphs/src/pages/add_validation/ui.rs @@ -0,0 +1,79 @@ +use super::state::{ValidationPageState, ValidationSetForm}; + +const ADMIN_CSS: &str = include_str!("../../../static/admin.css"); + +pub(crate) fn render_page(page: &ValidationPageState) -> String { + let profiles = datalist("profiles", &page.profiles); + let tables = datalist("tables", &page.tables); + let error = page.error.as_deref().map(render_error).unwrap_or_default(); + let (title, action, mut identity) = if page.reusable_rule { + ( + "Add reusable validation rule", + "/admin/validation/rules", + format!("", crate::escape_html(&page.form.rule_name), crate::escape_html(&page.form.description)), + ) + } else { + ( + "Add field validation", + "/admin/validation", + format!("", crate::escape_html(&page.form.table_name), crate::escape_html(&page.form.data_key)), + ) + }; + let profile_field = if page.reusable_rule { + let set_field = if page.reusable_rule { + String::new() + } else { + format!("", crate::escape_html(&page.form.validation_set_name)) + }; + identity.push_str(&format!( + "{set_field}", + crate::escape_html(&page.form.pattern_rules), + crate::escape_html(&page.form.pattern_description), + )); + String::new() + } else { + format!("", crate::escape_html(&page.form.profile_name)) + }; + format!( + "{title}
Komp Accounting
← Admin panel

Validation

{title}

{profile_field}{identity}
{}{}{}{}{}
{profiles}{tables}
{error}
Cancel
", + crate::escape_html(&page.form.minimum), + crate::escape_html(&page.form.maximum), + crate::escape_html(&page.form.warn_at), + crate::escape_html(&page.form.allowed_values), + crate::escape_html(&page.form.mask_pattern), + crate::escape_html(&page.form.mask_input_char), + crate::escape_html(&page.form.mask_template_char), + checkbox("required", "Required", page.form.required), + checkbox("allow_empty", "Allow empty", page.form.allow_empty), + checkbox("case_insensitive", "Case insensitive", page.form.case_insensitive), + checkbox("external_validation_enabled", "External validation", page.form.external_validation_enabled), + checkbox("locked", "Lock configuration", page.form.locked), + ) +} + +pub(crate) fn render_set_page(form: &ValidationSetForm, error: Option<&str>) -> String { + let error = error.map(render_error).unwrap_or_default(); + format!( + "Add validation set
Komp Accounting
← Admin panel

Global validation library

Add validation set

Compose an ordered set from existing reusable rule names.

{error}
Cancel
", + crate::escape_html(&form.name), + crate::escape_html(&form.description), + crate::escape_html(&form.global_rules), + ) +} + +fn datalist(id: &str, values: &[String]) -> String { + let options = values.iter().map(|value| format!("", crate::escape_html(value))).collect::(); + format!("{options}") +} + +fn checkbox(name: &str, label: &str, checked: bool) -> String { + format!("", if checked { "checked" } else { "" }) +} + +pub(crate) fn render_error(message: &str) -> String { + format!("
Could not save validation

{}

", crate::escape_html(message)) +} + +pub(crate) fn render_success(message: &str) -> String { + format!("
Validation saved

{}

", crate::escape_html(message)) +} diff --git a/graphs/src/pages/admin/admin/loader.rs b/graphs/src/pages/admin/admin/loader.rs new file mode 100644 index 0000000..72c9eb5 --- /dev/null +++ b/graphs/src/pages/admin/admin/loader.rs @@ -0,0 +1,137 @@ +use axum::http::HeaderMap; + +use crate::{ + AppState, + auth::GetAuthorizationRequest, + definitions::{common::Empty, table_structure::GetTableStructureRequest}, + services::{AuthenticationError, authenticated_request}, +}; + +use super::state::{ + AdminPageState, AdminSelection, ColumnView, LoadError, ProfileView, TableView, +}; + +pub(crate) async fn load_admin_page( + state: AppState, + headers: &HeaderMap, + selection: AdminSelection, +) -> Result { + let authorization_request = authenticated_request(headers, GetAuthorizationRequest {}) + .map_err(authentication_error)?; + let mut auth = state.auth; + let authorization = auth + .get_authorization(authorization_request) + .await + .map_err(|error| match error.code() { + tonic::Code::Unauthenticated => LoadError::Unauthenticated, + tonic::Code::PermissionDenied => LoadError::Forbidden, + _ => LoadError::Backend(error.message().to_string()), + })? + .into_inner(); + + if authorization.role != "admin" { + return Err(LoadError::Forbidden); + } + + let mut definitions = state.definitions; + let profile_tree = definitions + .get_profile_tree(authenticated_request(headers, Empty {}).map_err(authentication_error)?) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + + let profiles = profile_tree + .profiles + .iter() + .map(|profile| ProfileView { + name: profile.name.clone(), + table_count: profile.tables.len(), + }) + .collect::>(); + + let selected_profile = (!selection.profile.is_empty()).then_some(selection.profile); + let profile = match selected_profile.as_deref() { + Some(name) => Some( + profile_tree + .profiles + .iter() + .find(|profile| profile.name == name) + .ok_or_else(|| LoadError::InvalidSelection(format!("Unknown profile '{name}'")))?, + ), + None => None, + }; + + let tables = profile + .map(|profile| { + profile + .tables + .iter() + .map(|table| TableView { + name: table.name.clone(), + depends_on: table.depends_on.clone(), + row_display_column: table.row_display_column.clone(), + }) + .collect::>() + }) + .unwrap_or_default(); + + let selected_table = (!selection.table.is_empty()).then_some(selection.table); + if let Some(table_name) = selected_table.as_deref() { + if profile.is_none() || !tables.iter().any(|table| table.name == table_name) { + return Err(LoadError::InvalidSelection(format!( + "Table '{table_name}' is not part of the selected profile" + ))); + } + } + + let columns = match (selected_profile.as_deref(), selected_table.as_deref()) { + (Some(profile_name), Some(table_name)) => { + let request = GetTableStructureRequest { + profile_name: profile_name.to_string(), + table_names: vec![table_name.to_string()], + }; + let mut structures = state.structures; + let response = structures + .get_table_structure( + authenticated_request(headers, request).map_err(authentication_error)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + response + .table_structures + .get(table_name) + .ok_or_else(|| { + LoadError::Backend(format!( + "The backend did not return the structure for '{table_name}'" + )) + })? + .columns + .iter() + .map(|column| ColumnView { + name: column.name.clone(), + data_type: column.data_type.clone(), + nullable: column.is_nullable, + primary_key: column.is_primary_key, + recompute_on_dependency_change: column.recompute_on_dependency_change, + }) + .collect() + } + _ => Vec::new(), + }; + + Ok(AdminPageState { + role: authorization.role, + profiles, + selected_profile, + tables, + selected_table, + columns, + }) +} + +fn authentication_error(error: AuthenticationError) -> LoadError { + match error { + AuthenticationError::Missing | AuthenticationError::Invalid => LoadError::Unauthenticated, + } +} diff --git a/graphs/src/pages/admin/admin/logic.rs b/graphs/src/pages/admin/admin/logic.rs new file mode 100644 index 0000000..e21f7f7 --- /dev/null +++ b/graphs/src/pages/admin/admin/logic.rs @@ -0,0 +1,72 @@ +use axum::{ + extract::{Query, State}, + http::{HeaderMap, HeaderValue, header}, + response::{Html, IntoResponse, Redirect, Response}, +}; + +use crate::AppState; + +use super::{ + loader::load_admin_page, + state::{AdminSelection, LoadError}, + ui, +}; + +pub(crate) async fn admin_page( + State(state): State, + headers: HeaderMap, + Query(selection): Query, +) -> Response { + match load_admin_page(state, &headers, selection).await { + Ok(page) => Html(ui::render_page(&page)).into_response(), + Err(error) => error_response(error), + } +} + +pub(crate) async fn admin_workspace( + State(state): State, + headers: HeaderMap, + Query(selection): Query, +) -> Response { + match load_admin_page(state, &headers, selection).await { + Ok(page) => Html(ui::render_workspace(&page)).into_response(), + Err(error) => error_response(error), + } +} + +pub(crate) async fn logout() -> Response { + let mut response = Html(String::new()).into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_static( + "analytics_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", + ), + ); + response + .headers_mut() + .insert("hx-redirect", HeaderValue::from_static("/login")); + response +} + +fn error_response(error: LoadError) -> Response { + match error { + LoadError::Unauthenticated => Redirect::to("/login").into_response(), + LoadError::Forbidden => ( + axum::http::StatusCode::FORBIDDEN, + Html(ui::render_error( + "This account is not allowed to open the admin panel.", + )), + ) + .into_response(), + LoadError::InvalidSelection(message) => ( + axum::http::StatusCode::BAD_REQUEST, + Html(ui::render_error(&message)), + ) + .into_response(), + LoadError::Backend(message) => ( + axum::http::StatusCode::BAD_GATEWAY, + Html(ui::render_error(&message)), + ) + .into_response(), + } +} diff --git a/graphs/src/pages/admin/admin/mod.rs b/graphs/src/pages/admin/admin/mod.rs new file mode 100644 index 0000000..8ef64e6 --- /dev/null +++ b/graphs/src/pages/admin/admin/mod.rs @@ -0,0 +1,18 @@ +mod loader; +mod logic; +mod state; +mod ui; + +use axum::{ + Router, + routing::{get, post}, +}; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new() + .route("/admin", get(logic::admin_page)) + .route("/admin/workspace", get(logic::admin_workspace)) + .route("/logout", post(logic::logout)) +} diff --git a/graphs/src/pages/admin/admin/state.rs b/graphs/src/pages/admin/admin/state.rs new file mode 100644 index 0000000..87a3a61 --- /dev/null +++ b/graphs/src/pages/admin/admin/state.rs @@ -0,0 +1,47 @@ +#[derive(Debug, Default, serde::Deserialize)] +pub(crate) struct AdminSelection { + #[serde(default)] + pub profile: String, + #[serde(default)] + pub table: String, +} + +#[derive(Debug)] +pub(crate) struct AdminPageState { + pub role: String, + pub profiles: Vec, + pub selected_profile: Option, + pub tables: Vec, + pub selected_table: Option, + pub columns: Vec, +} + +#[derive(Debug)] +pub(crate) struct ProfileView { + pub name: String, + pub table_count: usize, +} + +#[derive(Debug)] +pub(crate) struct TableView { + pub name: String, + pub depends_on: Vec, + pub row_display_column: String, +} + +#[derive(Debug)] +pub(crate) struct ColumnView { + pub name: String, + pub data_type: String, + pub nullable: bool, + pub primary_key: bool, + pub recompute_on_dependency_change: bool, +} + +#[derive(Debug)] +pub(crate) enum LoadError { + Unauthenticated, + Forbidden, + InvalidSelection(String), + Backend(String), +} diff --git a/graphs/src/pages/admin/admin/ui.rs b/graphs/src/pages/admin/admin/ui.rs new file mode 100644 index 0000000..33081e1 --- /dev/null +++ b/graphs/src/pages/admin/admin/ui.rs @@ -0,0 +1,141 @@ +use super::state::AdminPageState; + +const ADMIN_CSS: &str = include_str!("../../../../static/admin.css"); + +pub(crate) fn render_page(page: &AdminPageState) -> String { + format!( + "Admin panel
Komp Accounting{}

Workspace

Admin panel

Browse profiles, tables, and their physical columns.

{}
", + crate::escape_html(&page.role), + render_workspace(page), + ) +} + +pub(crate) fn render_workspace(page: &AdminPageState) -> String { + format!( + "
{}{}{}
", + render_profiles(page), + render_tables(page), + render_columns(page), + ) +} + +pub(crate) fn render_error(message: &str) -> String { + format!( + "

Admin panel unavailable

{}

Try again
", + crate::escape_html(message), + ) +} + +fn render_profiles(page: &AdminPageState) -> String { + let items = if page.profiles.is_empty() { + "

No profiles available.

".to_string() + } else { + page.profiles + .iter() + .map(|profile| { + let selected = page.selected_profile.as_deref() == Some(profile.name.as_str()); + format!( + "
", + if selected { "selected" } else { "" }, + crate::escape_html(&profile.name), + crate::escape_html(&profile.name), + profile.table_count, + ) + }) + .collect() + }; + format!("

Profiles

{}
{items}
", page.profiles.len()) +} + +fn render_tables(page: &AdminPageState) -> String { + let items = if page.selected_profile.is_none() { + "

Select a profile to see its tables.

".to_string() + } else if page.tables.is_empty() { + "

This profile has no tables.

".to_string() + } else { + page.tables + .iter() + .map(|table| { + let selected = page.selected_table.as_deref() == Some(table.name.as_str()); + let dependencies = if table.depends_on.is_empty() { + "No dependencies".to_string() + } else { + format!("Depends on {}", table.depends_on.join(", ")) + }; + format!( + "
", + crate::escape_html(page.selected_profile.as_deref().unwrap_or_default()), + if selected { "selected" } else { "" }, + crate::escape_html(&table.name), + crate::escape_html(&table.name), + crate::escape_html(&dependencies), + crate::escape_html(&table.row_display_column), + ) + }) + .collect() + }; + format!("

Tables

{}
{items}
", page.tables.len()) +} + +fn render_columns(page: &AdminPageState) -> String { + let items = if page.selected_table.is_none() { + "

Select a table to inspect its columns.

".to_string() + } else if page.columns.is_empty() { + "

This table has no visible columns.

".to_string() + } else { + page.columns + .iter() + .map(|column| { + let mut flags = Vec::new(); + if column.primary_key { + flags.push("primary key"); + } + if column.nullable { + flags.push("nullable"); + } else { + flags.push("required"); + } + if column.recompute_on_dependency_change { + flags.push("recomputed"); + } + format!( + "
{}{}{}
", + crate::escape_html(&column.name), + crate::escape_html(&column.data_type), + flags.join(" · "), + ) + }) + .collect() + }; + format!("

Columns

{}
{items}
", page.columns.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pages::admin::admin::state::AdminPageState; + + #[test] + fn dashboard_links_every_admin_action() { + let page = AdminPageState { + role: "admin".to_string(), + profiles: Vec::new(), + selected_profile: None, + tables: Vec::new(), + selected_table: None, + columns: Vec::new(), + }; + let html = render_page(&page); + for route in [ + "/admin/tables/new", + "/admin/logic/new", + "/admin/validation/new", + "/admin/validation/sets/new", + "/admin/import", + "/admin/export", + "/logout", + ] { + assert!(html.contains(route), "missing admin action route {route}"); + } + } +} diff --git a/graphs/src/pages/admin/mod.rs b/graphs/src/pages/admin/mod.rs new file mode 100644 index 0000000..babc793 --- /dev/null +++ b/graphs/src/pages/admin/mod.rs @@ -0,0 +1 @@ +pub(crate) mod admin; diff --git a/graphs/src/pages/import_export/common/csv.rs b/graphs/src/pages/import_export/common/csv.rs new file mode 100644 index 0000000..6d79ad7 --- /dev/null +++ b/graphs/src/pages/import_export/common/csv.rs @@ -0,0 +1,75 @@ +pub(crate) fn parse_csv(input: &str) -> Result>, String> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut chars = input.chars().peekable(); + let mut quoted = false; + while let Some(ch) = chars.next() { + match ch { + '"' if quoted && chars.peek() == Some(&'"') => { + field.push('"'); + let _ = chars.next(); + } + '"' => quoted = !quoted, + ',' if !quoted => row.push(std::mem::take(&mut field)), + '\n' if !quoted => { + if field.ends_with('\r') { + field.pop(); + } + row.push(std::mem::take(&mut field)); + if row.iter().any(|value| !value.is_empty()) { + rows.push(std::mem::take(&mut row)); + } else { + row.clear(); + } + } + _ => field.push(ch), + } + } + if quoted { + return Err("CSV contains an unterminated quoted value.".to_string()); + } + if field.ends_with('\r') { + field.pop(); + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + if row.iter().any(|value| !value.is_empty()) { + rows.push(row); + } + } + if rows.is_empty() { + Err("CSV is empty.".to_string()) + } else { + Ok(rows) + } +} + +pub(crate) fn write_record(output: &mut String, fields: &[String]) { + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(','); + } + if field.contains([',', '"', '\n', '\r']) { + output.push('"'); + output.push_str(&field.replace('"', "\"\"")); + output.push('"'); + } else { + output.push_str(field); + } + } + output.push('\n'); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quoted_multiline_csv_round_trips() { + let fields = vec!["name".to_string(), "hello, \"world\"\nnext".to_string()]; + let mut csv = String::new(); + write_record(&mut csv, &fields); + assert_eq!(parse_csv(&csv).unwrap(), vec![fields]); + } +} diff --git a/graphs/src/pages/import_export/common/loader.rs b/graphs/src/pages/import_export/common/loader.rs new file mode 100644 index 0000000..452df1f --- /dev/null +++ b/graphs/src/pages/import_export/common/loader.rs @@ -0,0 +1,59 @@ +use axum::http::HeaderMap; + +use crate::{ + AppState, + auth::GetAuthorizationRequest, + definitions::common::Empty, + services::authenticated_request, +}; + +pub(crate) struct Catalog { + pub profiles: Vec, +} + +pub(crate) struct Profile { + pub name: String, + pub tables: Vec, +} + +pub(crate) async fn load_catalog( + state: AppState, + headers: &HeaderMap, +) -> Result { + let request = authenticated_request(headers, GetAuthorizationRequest {}) + .map_err(|_| LoadError::Unauthenticated)?; + let mut auth = state.auth; + let authorization = auth + .get_authorization(request) + .await + .map_err(|error| match error.code() { + tonic::Code::Unauthenticated => LoadError::Unauthenticated, + _ => LoadError::Backend(error.message().to_string()), + })? + .into_inner(); + if authorization.role != "admin" { + return Err(LoadError::Forbidden); + } + let mut definitions = state.definitions; + let tree = definitions + .get_profile_tree(authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + Ok(Catalog { + profiles: tree + .profiles + .into_iter() + .map(|profile| Profile { + name: profile.name, + tables: profile.tables.into_iter().map(|table| table.name).collect(), + }) + .collect(), + }) +} + +pub(crate) enum LoadError { + Unauthenticated, + Forbidden, + Backend(String), +} diff --git a/graphs/src/pages/import_export/common/mod.rs b/graphs/src/pages/import_export/common/mod.rs new file mode 100644 index 0000000..4d10c8b --- /dev/null +++ b/graphs/src/pages/import_export/common/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod csv; +pub(crate) mod loader; +pub(crate) mod schema; diff --git a/graphs/src/pages/import_export/common/schema.rs b/graphs/src/pages/import_export/common/schema.rs new file mode 100644 index 0000000..4644e37 --- /dev/null +++ b/graphs/src/pages/import_export/common/schema.rs @@ -0,0 +1,55 @@ +use std::collections::HashMap; + +use prost_types::{NullValue, Value, value::Kind}; + +use crate::definitions::table_structure::TableStructureResponse; + +pub(crate) fn exportable_columns(schema: &TableStructureResponse) -> Vec { + schema + .columns + .iter() + .filter(|column| { + !column.is_primary_key + && column.name != "id" + && column.name != "deleted" + && column.name != "created_at" + }) + .map(|column| column.name.clone()) + .collect() +} + +pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap { + schema + .columns + .iter() + .map(|column| (column.name.clone(), column.data_type.clone())) + .collect() +} + +pub(crate) fn csv_value(raw: &str, data_type: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(Value { + kind: Some(Kind::NullValue(NullValue::NullValue as i32)), + }); + } + let normalized = data_type.to_ascii_uppercase(); + let kind = if normalized == "BOOLEAN" || normalized == "BOOL" { + match raw.to_ascii_lowercase().as_str() { + "true" | "t" | "1" | "yes" | "y" => Kind::BoolValue(true), + "false" | "f" | "0" | "no" | "n" => Kind::BoolValue(false), + _ => return Err(format!("Invalid boolean value '{raw}'")), + } + } else if matches!( + normalized.as_str(), + "INTEGER" | "INT" | "INT4" | "INT2" | "BIGINT" | "INT8" + ) { + let integer = raw + .parse::() + .map_err(|_| format!("Invalid integer value '{raw}'"))?; + Kind::NumberValue(integer as f64) + } else { + Kind::StringValue(raw.to_string()) + }; + Ok(Value { kind: Some(kind) }) +} diff --git a/graphs/src/pages/import_export/export/loader.rs b/graphs/src/pages/import_export/export/loader.rs new file mode 100644 index 0000000..01bb551 --- /dev/null +++ b/graphs/src/pages/import_export/export/loader.rs @@ -0,0 +1,14 @@ +use axum::http::HeaderMap; + +use crate::AppState; + +use super::{super::common::loader::{LoadError, load_catalog}, state::ExportPageState}; + +pub(crate) async fn load_page( + state: AppState, + headers: &HeaderMap, +) -> Result { + Ok(ExportPageState { + catalog: load_catalog(state, headers).await?, + }) +} diff --git a/graphs/src/pages/import_export/export/logic.rs b/graphs/src/pages/import_export/export/logic.rs new file mode 100644 index 0000000..95b3458 --- /dev/null +++ b/graphs/src/pages/import_export/export/logic.rs @@ -0,0 +1,164 @@ +use axum::{ + Form, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{Html, IntoResponse, Redirect, Response}, +}; + +use crate::{ + AppState, + definitions::{ + table_structure::GetTableStructureRequest, + tables_data::{GetTableDataByPositionRequest, GetTableDataCountRequest}, + }, + services::authenticated_request, +}; + +use super::{ + super::common::{ + csv::write_record, + loader::LoadError, + schema::exportable_columns, + }, + loader::load_page, + state::ExportForm, + ui, +}; + +struct ExportTable { + name: String, + columns: Vec, + count: u64, +} + +pub(crate) async fn export_page(State(state): State, headers: HeaderMap) -> Response { + match load_page(state, &headers).await { + Ok(page) => Html(ui::render_page(&page)).into_response(), + Err(error) => load_error(error), + } +} + +pub(crate) async fn export_csv( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let catalog = match load_page(state.clone(), &headers).await { + Ok(page) => page.catalog, + Err(error) => return load_error(error), + }; + if cross_site(&headers) { + return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); + } + let (profile_name, table_names) = match form.targets() { + Ok(targets) => targets, + Err(message) => return (StatusCode::BAD_REQUEST, Html(ui::render_error(&message))).into_response(), + }; + let Some(profile) = catalog.profiles.iter().find(|profile| profile.name == profile_name) else { + return (StatusCode::BAD_REQUEST, Html(ui::render_error("Unknown profile."))).into_response(); + }; + if table_names.iter().any(|name| !profile.tables.contains(name)) { + return (StatusCode::BAD_REQUEST, Html(ui::render_error("One or more tables do not belong to the selected profile."))).into_response(); + } + + let mut tables = Vec::new(); + for table_name in &table_names { + let structure_request = GetTableStructureRequest { + profile_name: profile_name.clone(), + table_names: vec![table_name.clone()], + }; + let mut structures = state.structures.clone(); + let structure = match structures + .get_table_structure(match authenticated_request(&headers, structure_request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }) + .await + { + Ok(response) => response.into_inner().table_structures.remove(table_name), + Err(error) => return backend_error(error.message()), + }; + let Some(structure) = structure else { + return backend_error("The backend omitted a requested table structure."); + }; + let count_request = GetTableDataCountRequest { + profile_name: profile_name.clone(), + table_name: table_name.clone(), + }; + let mut data = state.tables_data.clone(); + let count = match data + .get_table_data_count(match authenticated_request(&headers, count_request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }) + .await + { + Ok(response) => response.into_inner().count, + Err(error) => return backend_error(error.message()), + }; + let Ok(count) = u64::try_from(count) else { + return backend_error("The backend returned a negative row count."); + }; + tables.push(ExportTable { name: table_name.clone(), columns: exportable_columns(&structure), count }); + } + + let mut csv = String::new(); + if tables.len() > 1 { + let table_headers = tables.iter().flat_map(|table| std::iter::repeat_n(table.name.clone(), table.columns.len())).collect::>(); + write_record(&mut csv, &table_headers); + } + let headers_row = tables.iter().flat_map(|table| table.columns.clone()).collect::>(); + write_record(&mut csv, &headers_row); + let max_count = tables.iter().map(|table| table.count).max().unwrap_or(0); + if max_count > i32::MAX as u64 { + return backend_error("The export exceeds the supported row-position range."); + } + for position in 1..=max_count { + let mut row = Vec::new(); + for table in &tables { + if position > table.count { + row.extend(std::iter::repeat_n(String::new(), table.columns.len())); + continue; + } + let request = GetTableDataByPositionRequest { + profile_name: profile_name.clone(), + table_name: table.name.clone(), + position: position as i32, + }; + let mut data = state.tables_data.clone(); + let response = match data.get_table_data_by_position(match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }).await { + Ok(response) => response.into_inner(), + Err(error) => return backend_error(error.message()), + }; + row.extend(table.columns.iter().map(|column| response.data.get(column).cloned().unwrap_or_default())); + } + write_record(&mut csv, &row); + } + + let filename = format!("{}_{}.csv", profile_name, table_names.join("_")); + let mut response = csv.into_response(); + response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/csv; charset=utf-8")); + if let Ok(value) = HeaderValue::try_from(format!("attachment; filename=\"{filename}\"")) { + response.headers_mut().insert(header::CONTENT_DISPOSITION, value); + } + response +} + +fn load_error(error: LoadError) -> Response { + match error { + LoadError::Unauthenticated => Redirect::to("/login").into_response(), + LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(), + LoadError::Backend(message) => backend_error(&message), + } +} + +fn backend_error(message: &str) -> Response { + (StatusCode::BAD_GATEWAY, Html(ui::render_error(message))).into_response() +} + +fn cross_site(headers: &HeaderMap) -> bool { + headers.get("sec-fetch-site").is_some_and(|value| value == "cross-site") +} diff --git a/graphs/src/pages/import_export/export/mod.rs b/graphs/src/pages/import_export/export/mod.rs new file mode 100644 index 0000000..866f5cf --- /dev/null +++ b/graphs/src/pages/import_export/export/mod.rs @@ -0,0 +1,14 @@ +mod loader; +mod logic; +mod state; +mod ui; + +use axum::{Router, routing::{get, post}}; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new() + .route("/admin/export", get(logic::export_page)) + .route("/admin/export.csv", post(logic::export_csv)) +} diff --git a/graphs/src/pages/import_export/export/state.rs b/graphs/src/pages/import_export/export/state.rs new file mode 100644 index 0000000..1ac1818 --- /dev/null +++ b/graphs/src/pages/import_export/export/state.rs @@ -0,0 +1,31 @@ +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct ExportForm { + #[serde(default)] + pub profile_name: String, + #[serde(default)] + pub table_names: String, +} + +pub(crate) struct ExportPageState { + pub catalog: super::super::common::loader::Catalog, +} + +impl ExportForm { + pub(crate) fn targets(&self) -> Result<(String, Vec), String> { + let profile = self.profile_name.trim(); + if profile.is_empty() { + return Err("Select a profile.".to_string()); + } + let tables = self + .table_names + .split(',') + .map(str::trim) + .filter(|table| !table.is_empty()) + .map(str::to_string) + .collect::>(); + if tables.is_empty() { + return Err("Enter at least one table.".to_string()); + } + Ok((profile.to_string(), tables)) + } +} diff --git a/graphs/src/pages/import_export/export/ui.rs b/graphs/src/pages/import_export/export/ui.rs new file mode 100644 index 0000000..14b04ae --- /dev/null +++ b/graphs/src/pages/import_export/export/ui.rs @@ -0,0 +1,15 @@ +use super::state::ExportPageState; + +const ADMIN_CSS: &str = include_str!("../../../../static/admin.css"); + +pub(crate) fn render_page(page: &ExportPageState) -> String { + let profiles = page.catalog.profiles.iter().map(|profile| format!("", crate::escape_html(&profile.name), crate::escape_html(&profile.name))).collect::(); + let tables = page.catalog.profiles.iter().flat_map(|profile| profile.tables.iter()).map(|table| format!("", crate::escape_html(table))).collect::(); + format!( + "Export CSV
Komp Accounting
← Admin panel

Data transfer

Export CSV

The download is generated from live table data through gRPC.

{tables}
Cancel
" + ) +} + +pub(crate) fn render_error(message: &str) -> String { + format!("
Could not export CSV

{}

", crate::escape_html(message)) +} diff --git a/graphs/src/pages/import_export/import/loader.rs b/graphs/src/pages/import_export/import/loader.rs new file mode 100644 index 0000000..94397c3 --- /dev/null +++ b/graphs/src/pages/import_export/import/loader.rs @@ -0,0 +1,17 @@ +use axum::http::HeaderMap; + +use crate::AppState; + +use super::{ + super::common::loader::{LoadError, load_catalog}, + state::{ImportForm, ImportPageState}, +}; + +pub(crate) async fn load_page( + state: AppState, + headers: &HeaderMap, + form: ImportForm, + error: Option, +) -> Result { + Ok(ImportPageState { catalog: load_catalog(state, headers).await?, form, error }) +} diff --git a/graphs/src/pages/import_export/import/logic.rs b/graphs/src/pages/import_export/import/logic.rs new file mode 100644 index 0000000..784b151 --- /dev/null +++ b/graphs/src/pages/import_export/import/logic.rs @@ -0,0 +1,240 @@ +use std::collections::{HashMap, HashSet}; + +use axum::{ + Form, + extract::State, + http::{HeaderMap, StatusCode}, + response::{Html, IntoResponse, Redirect, Response}, +}; + +use crate::{ + AppState, + definitions::{ + table_structure::GetTableStructureRequest, + tables_data::{PostTableDataBulkRequest, PostTableDataBulkRow}, + }, + services::authenticated_request, +}; + +use super::{ + super::common::{ + csv::parse_csv, + loader::LoadError, + schema::{column_types, csv_value, exportable_columns}, + }, + loader::load_page, + state::ImportForm, + ui, +}; + +struct ImportTable { + name: String, + columns: HashSet, + types: HashMap, +} + +pub(crate) async fn import_page(State(state): State, headers: HeaderMap) -> Response { + render_loaded(load_page(state, &headers, ImportForm::default(), None).await) +} + +pub(crate) async fn import_csv( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let submitted = form.clone(); + let page = match load_page(state.clone(), &headers, submitted.clone(), None).await { + Ok(page) => page, + Err(error) => return load_error(error), + }; + if cross_site(&headers) { + return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); + } + let (profile_name, table_names) = match form.targets() { + Ok(targets) => targets, + Err(message) => return render_loaded(load_page(state, &headers, submitted, Some(message)).await), + }; + let Some(profile) = page.catalog.profiles.iter().find(|profile| profile.name == profile_name) else { + return render_loaded(load_page(state, &headers, submitted, Some("Unknown profile.".to_string())).await); + }; + if table_names.iter().any(|name| !profile.tables.contains(name)) { + return render_loaded(load_page(state, &headers, submitted, Some("One or more target tables do not belong to the profile.".to_string())).await); + } + let rows = match parse_csv(&form.csv_data) { + Ok(rows) => rows, + Err(message) => return render_loaded(load_page(state, &headers, submitted, Some(message)).await), + }; + let (table_headers, columns, data_rows) = match split_headers(rows, &table_names) { + Ok(parts) => parts, + Err(message) => return render_loaded(load_page(state, &headers, submitted, Some(message)).await), + }; + + let mut tables = Vec::new(); + for table_name in &table_names { + let request = GetTableStructureRequest { + profile_name: profile_name.clone(), + table_names: vec![table_name.clone()], + }; + let mut structures = state.structures.clone(); + let structure = match structures.get_table_structure(match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }).await { + Ok(response) => response.into_inner().table_structures.remove(table_name), + Err(error) => return backend_error(error.message()), + }; + let Some(structure) = structure else { + return backend_error("The backend omitted a requested table structure."); + }; + tables.push(ImportTable { + name: table_name.clone(), + columns: exportable_columns(&structure).into_iter().collect(), + types: column_types(&structure), + }); + } + + let mut inserted = 0usize; + for table in &tables { + let positions = columns + .iter() + .enumerate() + .filter(|(index, column)| { + table_headers.as_ref().map_or(table_names.len() == 1, |headers| { + headers.get(*index).is_some_and(|name| name == &table.name) + }) && table.columns.contains(*column) + }) + .map(|(index, column)| (index, column.clone())) + .collect::>(); + if positions.is_empty() { + return render_loaded(load_page(state, &headers, submitted, Some(format!("CSV has no importable columns for table '{}'.", table.name))).await); + } + for (index, column) in columns.iter().enumerate() { + let belongs = table_headers.as_ref().map_or(table_names.len() == 1, |headers| headers.get(index).is_some_and(|name| name == &table.name)); + if belongs && !table.columns.contains(column) { + return render_loaded(load_page(state, &headers, submitted, Some(format!("Column '{}' is not importable for table '{}'.", column, table.name))).await); + } + } + let converted = match data_rows + .iter() + .enumerate() + .map(|(row_index, row)| row_for_table(table, &positions, row).map_err(|error| format!("CSV row {}: {error}", row_index + 2))) + .collect::, _>>() + { + Ok(rows) => rows, + Err(message) => return render_loaded(load_page(state, &headers, submitted, Some(message)).await), + }; + for chunk in converted.chunks(1_000) { + let request = PostTableDataBulkRequest { + profile_name: profile_name.clone(), + table_name: table.name.clone(), + rows: chunk.to_vec(), + }; + let mut data = state.tables_data.clone(); + let response = match data.post_table_data_bulk(match authenticated_request(&headers, request) { + Ok(request) => request, + Err(_) => return Redirect::to("/login").into_response(), + }).await { + Ok(response) => response.into_inner(), + Err(error) => return backend_error(error.message()), + }; + inserted += response.responses.iter().filter(|row| row.inserted_id > 0).count(); + } + } + Html(ui::render_success(inserted, data_rows.len(), tables.len())).into_response() +} + +fn split_headers(rows: Vec>, tables: &[String]) -> Result<(Option>, Vec, Vec>), String> { + if tables.len() > 1 { + if rows.len() < 2 { + return Err("Multi-table CSV needs a table-header row and a column-header row.".to_string()); + } + let table_headers = rows[0].clone(); + if table_headers.iter().any(|name| !tables.contains(name)) { + return Err("The first CSV row must contain only selected table names.".to_string()); + } + let columns = rows[1].clone(); + if columns.len() != table_headers.len() { + return Err("Table and column header rows have different lengths.".to_string()); + } + validate_width(&rows[2..], columns.len())?; + Ok((Some(table_headers), columns, rows[2..].to_vec())) + } else { + let columns = rows[0].clone(); + validate_width(&rows[1..], columns.len())?; + Ok((None, columns, rows[1..].to_vec())) + } +} + +fn validate_width(rows: &[Vec], width: usize) -> Result<(), String> { + if rows.iter().any(|row| row.len() != width) { + Err("A CSV data row has a different number of fields than the header.".to_string()) + } else if rows.is_empty() { + Err("CSV contains headers but no data rows.".to_string()) + } else { + Ok(()) + } +} + +fn row_for_table(table: &ImportTable, positions: &[(usize, String)], row: &[String]) -> Result { + let mut data = HashMap::new(); + for (index, column) in positions { + let data_type = table.types.get(column).ok_or_else(|| format!("Missing type for column '{column}'"))?; + let value = csv_value(row.get(*index).map(String::as_str).unwrap_or_default(), data_type)?; + data.insert(column.clone(), value); + } + Ok(PostTableDataBulkRow { data }) +} + +fn render_loaded(result: Result) -> Response { + match result { + Ok(page) => Html(ui::render_page(&page)).into_response(), + Err(error) => load_error(error), + } +} + +fn load_error(error: LoadError) -> Response { + match error { + LoadError::Unauthenticated => Redirect::to("/login").into_response(), + LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(), + LoadError::Backend(message) => backend_error(&message), + } +} + +fn backend_error(message: &str) -> Response { + (StatusCode::BAD_GATEWAY, Html(ui::render_error(message))).into_response() +} + +fn cross_site(headers: &HeaderMap) -> bool { + headers.get("sec-fetch-site").is_some_and(|value| value == "cross-site") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_single_table_csv_headers() { + let rows = vec![ + vec!["name".to_string(), "amount".to_string()], + vec!["One".to_string(), "10".to_string()], + ]; + let (tables, columns, data) = split_headers(rows, &["invoice".to_string()]).unwrap(); + assert!(tables.is_none()); + assert_eq!(columns, vec!["name", "amount"]); + assert_eq!(data.len(), 1); + } + + #[test] + fn splits_multi_table_csv_headers() { + let rows = vec![ + vec!["invoice".to_string(), "customer".to_string()], + vec!["number".to_string(), "name".to_string()], + vec!["I-1".to_string(), "Acme".to_string()], + ]; + let targets = vec!["invoice".to_string(), "customer".to_string()]; + let (tables, columns, data) = split_headers(rows, &targets).unwrap(); + assert_eq!(tables.unwrap(), targets); + assert_eq!(columns, vec!["number", "name"]); + assert_eq!(data.len(), 1); + } +} diff --git a/graphs/src/pages/import_export/import/mod.rs b/graphs/src/pages/import_export/import/mod.rs new file mode 100644 index 0000000..3732b1c --- /dev/null +++ b/graphs/src/pages/import_export/import/mod.rs @@ -0,0 +1,15 @@ +mod loader; +mod logic; +mod state; +mod ui; + +use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}}; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new() + .route("/admin/import", get(logic::import_page)) + .route("/admin/import", post(logic::import_csv)) + .layer(DefaultBodyLimit::max(128 * 1024 * 1024)) +} diff --git a/graphs/src/pages/import_export/import/state.rs b/graphs/src/pages/import_export/import/state.rs new file mode 100644 index 0000000..a83108d --- /dev/null +++ b/graphs/src/pages/import_export/import/state.rs @@ -0,0 +1,38 @@ +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct ImportForm { + #[serde(default)] + pub profile_name: String, + #[serde(default)] + pub table_names: String, + #[serde(default)] + pub csv_data: String, +} + +pub(crate) struct ImportPageState { + pub catalog: super::super::common::loader::Catalog, + pub form: ImportForm, + pub error: Option, +} + +impl ImportForm { + pub(crate) fn targets(&self) -> Result<(String, Vec), String> { + let profile = self.profile_name.trim(); + if profile.is_empty() { + return Err("Select a profile.".to_string()); + } + let tables = self + .table_names + .split(',') + .map(str::trim) + .filter(|table| !table.is_empty()) + .map(str::to_string) + .collect::>(); + if tables.is_empty() { + return Err("Enter at least one target table.".to_string()); + } + if self.csv_data.trim().is_empty() { + return Err("Choose a CSV file or paste CSV data.".to_string()); + } + Ok((profile.to_string(), tables)) + } +} diff --git a/graphs/src/pages/import_export/import/ui.rs b/graphs/src/pages/import_export/import/ui.rs new file mode 100644 index 0000000..d89fd06 --- /dev/null +++ b/graphs/src/pages/import_export/import/ui.rs @@ -0,0 +1,22 @@ +use super::state::ImportPageState; + +const ADMIN_CSS: &str = include_str!("../../../../static/admin.css"); + +pub(crate) fn render_page(page: &ImportPageState) -> String { + let profiles = page.catalog.profiles.iter().map(|profile| format!("", crate::escape_html(&profile.name), if page.form.profile_name == profile.name { "selected" } else { "" }, crate::escape_html(&profile.name))).collect::(); + let tables = page.catalog.profiles.iter().flat_map(|profile| profile.tables.iter()).map(|table| format!("", crate::escape_html(table))).collect::(); + let error = page.error.as_deref().map(render_error).unwrap_or_default(); + format!( + "Import CSV
Komp Accounting
← Admin panel

Data transfer

Import CSV

Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.

{tables}
{error}
Cancel
", + crate::escape_html(&page.form.table_names), + crate::escape_html(&page.form.csv_data), + ) +} + +pub(crate) fn render_error(message: &str) -> String { + format!("
Could not import CSV

{}

", crate::escape_html(message)) +} + +pub(crate) fn render_success(inserted: usize, source_rows: usize, table_count: usize) -> String { + format!("
Import complete

Inserted {inserted} record(s) from {source_rows} CSV row(s) across {table_count} table(s).

") +} diff --git a/graphs/src/pages/import_export/mod.rs b/graphs/src/pages/import_export/mod.rs new file mode 100644 index 0000000..411924b --- /dev/null +++ b/graphs/src/pages/import_export/mod.rs @@ -0,0 +1,11 @@ +pub(crate) mod common; +pub(crate) mod export; +pub(crate) mod import; + +use axum::Router; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new().merge(export::router()).merge(import::router()) +} diff --git a/graphs/src/pages/mod.rs b/graphs/src/pages/mod.rs new file mode 100644 index 0000000..0aac224 --- /dev/null +++ b/graphs/src/pages/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod add_logic; +pub(crate) mod add_table; +pub(crate) mod add_validation; +pub(crate) mod admin; +pub(crate) mod import_export; diff --git a/graphs/src/services/mod.rs b/graphs/src/services/mod.rs new file mode 100644 index 0000000..2c96770 --- /dev/null +++ b/graphs/src/services/mod.rs @@ -0,0 +1,21 @@ +use axum::http::HeaderMap; +use tonic::{Request, metadata::MetadataValue}; + +#[derive(Debug)] +pub(crate) enum AuthenticationError { + Missing, + Invalid, +} + +pub(crate) fn authenticated_request( + headers: &HeaderMap, + message: T, +) -> Result, AuthenticationError> { + let token = crate::cookie_value(headers, "analytics_token") + .ok_or(AuthenticationError::Missing)?; + let value = MetadataValue::try_from(format!("Bearer {token}")) + .map_err(|_| AuthenticationError::Invalid)?; + let mut request = Request::new(message); + request.metadata_mut().insert("authorization", value); + Ok(request) +} diff --git a/graphs/static/admin.css b/graphs/static/admin.css new file mode 100644 index 0000000..4c58815 --- /dev/null +++ b/graphs/static/admin.css @@ -0,0 +1,57 @@ +* { box-sizing: border-box; } +:root { color: #17202a; background: #f3f5f7; font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; } +body { margin: 0; } +button, input { font: inherit; } +.topbar { min-height: 58px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; color: #eef4ff; background: #152238; } +.topbar > div, .topbar nav { display: flex; align-items: center; gap: 18px; } +.topbar a, .link-button { color: #cbd7e8; text-decoration: none; background: transparent; border: 0; cursor: pointer; padding: 8px 2px; } +.topbar a:hover, .link-button:hover, .topbar a.active { color: white; } +.topbar a.active { border-bottom: 2px solid #68a4ff; } +.role { color: #9fb0c8; font-size: 12px; } +main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; } +.heading { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 22px; } +.heading h1 { margin: 0; font-size: 28px; } +.heading p { margin: 5px 0 0; color: #687384; } +.eyebrow { color: #2563eb !important; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.actions { display: flex; flex-wrap: wrap; gap: 8px; } +.actions a, .actions span { padding: 8px 12px; border: 1px solid #cfd7e1; border-radius: 7px; color: #283548; background: white; text-decoration: none; } +.workspace { display: grid; grid-template-columns: minmax(210px, .75fr) minmax(280px, 1.15fr) minmax(300px, 1.25fr); min-height: 560px; border: 1px solid #d9dfe7; border-radius: 11px; overflow: hidden; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); } +.pane + .pane { border-left: 1px solid #e1e5eb; } +.pane-title { height: 54px; padding: 0 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5e8ed; background: #fafbfc; } +.pane-title h2 { margin: 0; font-size: 14px; } +.pane-title span { color: #7c8796; font-size: 12px; } +.pane-list { padding: 10px; } +.pane-list form { margin: 0; } +.browser-item { width: 100%; display: grid; gap: 3px; padding: 11px 12px; text-align: left; border: 1px solid transparent; border-radius: 7px; color: #253246; background: transparent; cursor: pointer; } +.browser-item:hover { background: #f2f6fc; } +.browser-item.selected { border-color: #a9c7f6; background: #eaf2ff; } +.browser-item small, .column small { color: #788495; } +.column { display: grid; grid-template-columns: minmax(100px, 1fr) auto; gap: 3px 12px; padding: 10px 11px; border-bottom: 1px solid #edf0f3; } +.column code { color: #315078; font-size: 12px; } +.column small { grid-column: 1 / -1; } +.empty { margin: 8px; color: #7c8796; } +.error-page { width: min(600px, calc(100% - 32px)); margin: 15vh auto; padding: 24px; border: 1px solid #f1c4c0; border-radius: 10px; background: white; } +.error-page h1 { margin-top: 0; } +.error-page p { color: #a33a31; } +select, input, textarea { width: 100%; padding: 9px 10px; border: 1px solid #cbd3dd; border-radius: 6px; color: #1e2938; background: white; } +textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; } +.form-main { width: min(900px, calc(100% - 32px)); } +.back-link { display: inline-block; margin-bottom: 14px; color: #315f9d; text-decoration: none; } +.form-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); } +.form-card h1 { margin: 0; } +.form-card > p { color: #687384; } +.form-grid { margin-top: 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 17px; } +.form-grid label { display: grid; align-content: start; gap: 6px; color: #465267; font-size: 12px; } +.form-grid label.wide { grid-column: 1 / -1; } +.form-grid small { color: #7c8796; } +.form-actions { margin-top: 20px; display: flex; justify-content: end; align-items: center; gap: 14px; } +.form-actions a { color: #59677a; } +.form-actions button { padding: 9px 16px; border: 0; border-radius: 6px; color: white; background: #2563eb; cursor: pointer; } +.form-error { margin-top: 16px; padding: 11px 13px; border: 1px solid #efc3bf; border-radius: 6px; color: #9d342d; background: #fff5f4; } +.form-error p { margin: 3px 0 0; white-space: pre-wrap; } +.form-success { margin-top: 16px; padding: 11px 13px; border: 1px solid #b9dec6; border-radius: 6px; color: #21643a; background: #f1fbf4; } +.form-success p { margin: 3px 0 0; } +.check-group { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 12px 20px; } +.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; } +.check input { width: auto; } +@media (max-width: 850px) { .heading { align-items: start; flex-direction: column; } .workspace { grid-template-columns: 1fr; } .pane + .pane { border-left: 0; border-top: 1px solid #e1e5eb; } .topbar { padding: 10px 16px; align-items: start; gap: 10px; } .topbar, .topbar nav { flex-wrap: wrap; } } diff --git a/graphs/static/index.html b/graphs/static/index.html index f68c5ae..4aa384f 100644 --- a/graphs/static/index.html +++ b/graphs/static/index.html @@ -64,7 +64,7 @@
-

Analytics graphs

Login
+

Analytics graphs