web admin panel working well
This commit is contained in:
32
Cargo.lock
generated
32
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
2
client
2
client
Submodule client updated: b56c2179dd...9d5d09322f
@@ -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 <http://127.0.0.1:3000/login> to log in, then use the analytics page at
|
||||
<http://127.0.0.1:3000>. The access token is kept in an HTTP-only cookie. The default gRPC endpoint is
|
||||
Open <http://127.0.0.1:3000/login> to log in. The admin panel is at
|
||||
<http://127.0.0.1:3000/admin> and analytics remains at <http://127.0.0.1:3000>.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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<Channel>,
|
||||
auth: AuthServiceClient<Channel>,
|
||||
definitions: TableDefinitionClient<Channel>,
|
||||
scripts: TableScriptClient<Channel>,
|
||||
structures: TableStructureServiceClient<Channel>,
|
||||
validations: TableValidationServiceClient<Channel>,
|
||||
tables_data: TablesDataClient<Channel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -124,7 +158,11 @@ pub async fn serve() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
.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<AppState>, Form(input): Form<LoginInput>) ->
|
||||
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(),
|
||||
|
||||
59
graphs/src/pages/add_logic/loader.rs
Normal file
59
graphs/src/pages/add_logic/loader.rs
Normal file
@@ -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<String>,
|
||||
) -> Result<AddLogicPageState, LoadError> {
|
||||
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),
|
||||
}
|
||||
78
graphs/src/pages/add_logic/logic.rs
Normal file
78
graphs/src/pages/add_logic/logic.rs
Normal file
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
render_loaded(load_page(state, &headers, CreateLogicForm::default(), None).await)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_logic(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CreateLogicForm>,
|
||||
) -> 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<super::state::AddLogicPageState, LoadError>) -> 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(),
|
||||
}
|
||||
}
|
||||
17
graphs/src/pages/add_logic/mod.rs
Normal file
17
graphs/src/pages/add_logic/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/logic/new", get(logic::new_logic_page))
|
||||
.route("/admin/logic", post(logic::create_logic))
|
||||
}
|
||||
52
graphs/src/pages/add_logic/state.rs
Normal file
52
graphs/src/pages/add_logic/state.rs
Normal file
@@ -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<TableOption>,
|
||||
pub form: CreateLogicForm,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct TableOption {
|
||||
pub id: i64,
|
||||
pub profile_name: String,
|
||||
pub table_name: String,
|
||||
}
|
||||
|
||||
impl CreateLogicForm {
|
||||
pub(crate) fn into_request(self) -> Result<PostTableScriptRequest, String> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
45
graphs/src/pages/add_logic/ui.rs
Normal file
45
graphs/src/pages/add_logic/ui.rs
Normal file
@@ -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!(
|
||||
"<option value=\"{}\" {}>{}.{}</option>",
|
||||
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::<String>();
|
||||
let error = page.error.as_deref().map(render_submission_error).unwrap_or_default();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Add logic</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Computed column</p><h1>Add logic</h1><p>Create or update a Steel script through the existing table-script service.</p><form hx-post=\"/admin/logic\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\" hx-disabled-elt=\"button[type=submit]\"><div class=\"form-grid\"><label class=\"wide\">Table<select name=\"table_definition_id\" required><option value=\"0\">Choose a table</option>{tables}</select></label><label>Logic name<input name=\"logic_name\" value=\"{}\" placeholder=\"invoice total\"></label><label>Target column<input name=\"target_column\" value=\"{}\" required placeholder=\"total\"></label><label class=\"wide\">Steel expression<textarea name=\"script\" rows=\"12\" required placeholder=\"(+ (get-var "subtotal") (get-var "tax"))\">{}</textarea></label><label class=\"wide\">Description<input name=\"description\" value=\"{}\"></label></div><div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Save logic</button></div></form></section></main></body></html>",
|
||||
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!(
|
||||
"<div class=\"form-error\"><strong>Could not save the logic</strong><p>{}</p></div>",
|
||||
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!("<p><strong>Warnings:</strong> {}</p>", crate::escape_html(warnings))
|
||||
};
|
||||
format!(
|
||||
"<div class=\"form-success\"><strong>Logic saved</strong><p>Script #{id} was saved.</p>{warnings}</div>"
|
||||
)
|
||||
}
|
||||
52
graphs/src/pages/add_table/loader.rs
Normal file
52
graphs/src/pages/add_table/loader.rs
Normal file
@@ -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<String>,
|
||||
) -> Result<AddTablePageState, LoadError> {
|
||||
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),
|
||||
}
|
||||
98
graphs/src/pages/add_table/logic.rs
Normal file
98
graphs/src/pages/add_table/logic.rs
Normal file
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
render_loaded(load_page(state, &headers, CreateTableForm::default(), None).await)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_table(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CreateTableForm>,
|
||||
) -> 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<super::state::AddTablePageState, LoadError>) -> 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(),
|
||||
}
|
||||
}
|
||||
17
graphs/src/pages/add_table/mod.rs
Normal file
17
graphs/src/pages/add_table/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/tables/new", get(logic::new_table_page))
|
||||
.route("/admin/tables", post(logic::create_table))
|
||||
}
|
||||
168
graphs/src/pages/add_table/state.rs
Normal file
168
graphs/src/pages/add_table/state.rs
Normal file
@@ -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<String>,
|
||||
pub form: CreateTableForm,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateTableForm {
|
||||
pub(crate) fn into_request(self) -> Result<PostTableDefinitionRequest, String> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
40
graphs/src/pages/add_table/ui.rs
Normal file
40
graphs/src/pages/add_table/ui.rs
Normal file
@@ -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!(
|
||||
"<option value=\"{}\" {}>{}</option>",
|
||||
crate::escape_html(profile),
|
||||
if page.form.profile_name == *profile { "selected" } else { "" },
|
||||
crate::escape_html(profile),
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
let error = page
|
||||
.error
|
||||
.as_deref()
|
||||
.map(render_submission_error)
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Add table</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Table definition</p><h1>Add table</h1><p>Create a table through the existing gRPC table-definition service.</p><form hx-post=\"/admin/tables\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\" hx-disabled-elt=\"button[type=submit]\"><div class=\"form-grid\"><label>Profile<select name=\"profile_name\" required><option value=\"\">Choose a profile</option>{options}</select></label><label>Table name<input name=\"table_name\" value=\"{}\" required placeholder=\"invoices\"></label><label class=\"wide\">Columns<textarea name=\"columns\" rows=\"9\" required placeholder=\"number: text:indexed issued_on: date amount: money:half-up,recompute\">{}</textarea><small>One per line: <code>name: type: optional flags</code>. Flags: indexed, half-up, recompute.</small></label><label>Additional indexed columns<input name=\"indexed_columns\" value=\"{}\" placeholder=\"number, issued_on\"></label><label>Base currency<input name=\"base_currency\" value=\"{}\" maxlength=\"3\" placeholder=\"EUR\"></label><label>Required links<input name=\"required_links\" value=\"{}\" placeholder=\"customer, address\"></label><label>Optional links<input name=\"optional_links\" value=\"{}\" placeholder=\"project\"></label><label>Row display column<input name=\"row_display_column\" value=\"{}\"></label></div><div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Create table</button></div></form></section></main></body></html>",
|
||||
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!(
|
||||
"<div class=\"form-error\"><strong>Could not create the table</strong><p>{}</p></div>",
|
||||
crate::escape_html(message),
|
||||
)
|
||||
}
|
||||
62
graphs/src/pages/add_validation/loader.rs
Normal file
62
graphs/src/pages/add_validation/loader.rs
Normal file
@@ -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<String>,
|
||||
) -> Result<ValidationPageState, LoadError> {
|
||||
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<String>,
|
||||
) -> Result<(ValidationSetForm, Option<String>), LoadError> {
|
||||
let _ = load_page(state, headers, ValidationForm::default(), true, None).await?;
|
||||
Ok((form, error))
|
||||
}
|
||||
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Forbidden,
|
||||
Backend(String),
|
||||
}
|
||||
142
graphs/src/pages/add_validation/logic.rs
Normal file
142
graphs/src/pages/add_validation/logic.rs
Normal file
@@ -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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, headers: HeaderMap, Form(form): Form<ValidationForm>,
|
||||
) -> 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<AppState>, headers: HeaderMap, Form(form): Form<ValidationForm>,
|
||||
) -> 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<AppState>, headers: HeaderMap, Form(form): Form<ValidationSetForm>,
|
||||
) -> 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<String>), 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<super::state::ValidationPageState, LoadError>) -> 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")
|
||||
}
|
||||
27
graphs/src/pages/add_validation/mod.rs
Normal file
27
graphs/src/pages/add_validation/mod.rs
Normal file
@@ -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<AppState> {
|
||||
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))
|
||||
}
|
||||
273
graphs/src/pages/add_validation/state.rs
Normal file
273
graphs/src/pages/add_validation/state.rs
Normal file
@@ -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<String>,
|
||||
pub tables: Vec<String>,
|
||||
pub form: ValidationForm,
|
||||
pub reusable_rule: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<UpsertValidationSetRequest, String> {
|
||||
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<UpdateFieldValidationRequest, String> {
|
||||
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<UpsertValidationRuleRequest, String> {
|
||||
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<ApplyValidationSetRequest, String> {
|
||||
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<FieldValidation, String> {
|
||||
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<String, String> {
|
||||
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<Option<u32>, 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<String> {
|
||||
value.split(',').map(str::trim).filter(|v| !v.is_empty()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn nonempty(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn parse_pattern_rules(value: &str, description: &str) -> Result<Option<PatternRules>, 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<PatternPosition, String> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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<u32, String> {
|
||||
value.trim().parse().map_err(|_| format!("Invalid pattern position '{}'.", value.trim()))
|
||||
}
|
||||
|
||||
fn parse_constraint(value: &str) -> Result<CharacterConstraint, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
79
graphs/src/pages/add_validation/ui.rs
Normal file
79
graphs/src/pages/add_validation/ui.rs
Normal file
@@ -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!("<label>Rule name<input name=\"rule_name\" value=\"{}\" required></label><label>Description<input name=\"description\" value=\"{}\"></label>", crate::escape_html(&page.form.rule_name), crate::escape_html(&page.form.description)),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Add field validation",
|
||||
"/admin/validation",
|
||||
format!("<label>Table<input name=\"table_name\" list=\"tables\" value=\"{}\" required></label><label>Column<input name=\"data_key\" value=\"{}\" required></label>", 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!("<label class=\"wide\">Apply named validation set instead<input name=\"validation_set_name\" value=\"{}\" placeholder=\"Leave empty to save the rules below\"></label>", crate::escape_html(&page.form.validation_set_name))
|
||||
};
|
||||
identity.push_str(&format!(
|
||||
"{set_field}<label class=\"wide\">Position rules<textarea name=\"pattern_rules\" rows=\"6\" placeholder=\"0-3 | alphabetic 4,5 | one-of=-,+ 6+ | regex=[0-9]\">{}</textarea><small>One per line: position | constraint. Positions: 0, 0-3, 4+, or 1,3,5. Constraints: alphabetic, numeric, alphanumeric, exact=x, one-of=a,b, regex=...</small></label><label class=\"wide\">Pattern description<input name=\"pattern_description\" value=\"{}\"></label>",
|
||||
crate::escape_html(&page.form.pattern_rules),
|
||||
crate::escape_html(&page.form.pattern_description),
|
||||
));
|
||||
String::new()
|
||||
} else {
|
||||
format!("<label>Profile<input name=\"profile_name\" list=\"profiles\" value=\"{}\" required></label>", crate::escape_html(&page.form.profile_name))
|
||||
};
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>{title}</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Validation</p><h1>{title}</h1><form hx-post=\"{action}\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\"><div class=\"form-grid\">{profile_field}{identity}<label>Minimum length<input name=\"minimum\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Maximum length<input name=\"maximum\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Warning threshold<input name=\"warn_at\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Count mode<select name=\"count_mode\"><option value=\"chars\">Characters</option><option value=\"bytes\">Bytes</option><option value=\"display-width\">Display width</option></select></label><label class=\"wide\">Allowed values<input name=\"allowed_values\" value=\"{}\" placeholder=\"draft, issued, paid\"></label><label>Display mask<input name=\"mask_pattern\" value=\"{}\" placeholder=\"####-##-##\"></label><label>Mask input character<input name=\"mask_input_char\" value=\"{}\" placeholder=\"#\"></label><label>Mask template character<input name=\"mask_template_char\" value=\"{}\" placeholder=\"_\"></label><div class=\"check-group\">{}{}{}{}{} </div></div>{profiles}{tables}<div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Save validation</button></div></form></section></main></body></html>",
|
||||
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!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Add validation set</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/admin/validation/rules/new\">New reusable rule</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Global validation library</p><h1>Add validation set</h1><p>Compose an ordered set from existing reusable rule names.</p><form hx-post=\"/admin/validation/sets\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\"><div class=\"form-grid\"><label>Set name<input name=\"name\" value=\"{}\" required></label><label>Description<input name=\"description\" value=\"{}\"></label><label class=\"wide\">Reusable rules<input name=\"global_rules\" value=\"{}\" required placeholder=\"required, invoice-number, max-length\"><small>Comma-separated and applied in this order.</small></label></div><div id=\"submission-status\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Save set</button></div></form></section></main></body></html>",
|
||||
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!("<option value=\"{}\"></option>", crate::escape_html(value))).collect::<String>();
|
||||
format!("<datalist id=\"{id}\">{options}</datalist>")
|
||||
}
|
||||
|
||||
fn checkbox(name: &str, label: &str, checked: bool) -> String {
|
||||
format!("<label class=\"check\"><input type=\"checkbox\" name=\"{name}\" value=\"true\" {}>{label}</label>", if checked { "checked" } else { "" })
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
format!("<div class=\"form-error\"><strong>Could not save validation</strong><p>{}</p></div>", crate::escape_html(message))
|
||||
}
|
||||
|
||||
pub(crate) fn render_success(message: &str) -> String {
|
||||
format!("<div class=\"form-success\"><strong>Validation saved</strong><p>{}</p></div>", crate::escape_html(message))
|
||||
}
|
||||
137
graphs/src/pages/admin/admin/loader.rs
Normal file
137
graphs/src/pages/admin/admin/loader.rs
Normal file
@@ -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<AdminPageState, LoadError> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.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,
|
||||
}
|
||||
}
|
||||
72
graphs/src/pages/admin/admin/logic.rs
Normal file
72
graphs/src/pages/admin/admin/logic.rs
Normal file
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<AdminSelection>,
|
||||
) -> 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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<AdminSelection>,
|
||||
) -> 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(),
|
||||
}
|
||||
}
|
||||
18
graphs/src/pages/admin/admin/mod.rs
Normal file
18
graphs/src/pages/admin/admin/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new()
|
||||
.route("/admin", get(logic::admin_page))
|
||||
.route("/admin/workspace", get(logic::admin_workspace))
|
||||
.route("/logout", post(logic::logout))
|
||||
}
|
||||
47
graphs/src/pages/admin/admin/state.rs
Normal file
47
graphs/src/pages/admin/admin/state.rs
Normal file
@@ -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<ProfileView>,
|
||||
pub selected_profile: Option<String>,
|
||||
pub tables: Vec<TableView>,
|
||||
pub selected_table: Option<String>,
|
||||
pub columns: Vec<ColumnView>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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),
|
||||
}
|
||||
141
graphs/src/pages/admin/admin/ui.rs
Normal file
141
graphs/src/pages/admin/admin/ui.rs
Normal file
@@ -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!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Admin panel</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong><span class=\"role\">{}</span></div><nav><a class=\"active\" href=\"/admin\">Admin</a><a href=\"/\">Analytics</a><form hx-post=\"/logout\" hx-swap=\"none\"><button class=\"link-button\" type=\"submit\">Log out</button></form></nav></header><main><section class=\"heading\"><div><p class=\"eyebrow\">Workspace</p><h1>Admin panel</h1><p>Browse profiles, tables, and their physical columns.</p></div><div class=\"actions\"><a href=\"/admin/tables/new\">Add table</a><a href=\"/admin/logic/new\">Add logic</a><a href=\"/admin/validation/new\">Add validation</a><a href=\"/admin/validation/sets/new\">Add rule</a><a href=\"/admin/import\">Import</a><a href=\"/admin/export\">Export</a></div></section><div id=\"admin-workspace\">{}</div></main></body></html>",
|
||||
crate::escape_html(&page.role),
|
||||
render_workspace(page),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_workspace(page: &AdminPageState) -> String {
|
||||
format!(
|
||||
"<div class=\"workspace\" aria-live=\"polite\">{}{}{}</div>",
|
||||
render_profiles(page),
|
||||
render_tables(page),
|
||||
render_columns(page),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
format!(
|
||||
"<div class=\"error-page\"><h1>Admin panel unavailable</h1><p>{}</p><a href=\"/admin\">Try again</a></div>",
|
||||
crate::escape_html(message),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_profiles(page: &AdminPageState) -> String {
|
||||
let items = if page.profiles.is_empty() {
|
||||
"<p class=\"empty\">No profiles available.</p>".to_string()
|
||||
} else {
|
||||
page.profiles
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
let selected = page.selected_profile.as_deref() == Some(profile.name.as_str());
|
||||
format!(
|
||||
"<form hx-get=\"/admin/workspace\" hx-target=\"#admin-workspace\" hx-swap=\"innerHTML\"><button class=\"browser-item {}\" type=\"submit\" name=\"profile\" value=\"{}\"><span>{}</span><small>{} tables</small></button></form>",
|
||||
if selected { "selected" } else { "" },
|
||||
crate::escape_html(&profile.name),
|
||||
crate::escape_html(&profile.name),
|
||||
profile.table_count,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
format!("<section class=\"pane\"><div class=\"pane-title\"><h2>Profiles</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", page.profiles.len())
|
||||
}
|
||||
|
||||
fn render_tables(page: &AdminPageState) -> String {
|
||||
let items = if page.selected_profile.is_none() {
|
||||
"<p class=\"empty\">Select a profile to see its tables.</p>".to_string()
|
||||
} else if page.tables.is_empty() {
|
||||
"<p class=\"empty\">This profile has no tables.</p>".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!(
|
||||
"<form hx-get=\"/admin/workspace\" hx-target=\"#admin-workspace\" hx-swap=\"innerHTML\"><input type=\"hidden\" name=\"profile\" value=\"{}\"><button class=\"browser-item {}\" type=\"submit\" name=\"table\" value=\"{}\"><span>{}</span><small>{} · display: {}</small></button></form>",
|
||||
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!("<section class=\"pane\"><div class=\"pane-title\"><h2>Tables</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", page.tables.len())
|
||||
}
|
||||
|
||||
fn render_columns(page: &AdminPageState) -> String {
|
||||
let items = if page.selected_table.is_none() {
|
||||
"<p class=\"empty\">Select a table to inspect its columns.</p>".to_string()
|
||||
} else if page.columns.is_empty() {
|
||||
"<p class=\"empty\">This table has no visible columns.</p>".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!(
|
||||
"<div class=\"column\"><span>{}</span><code>{}</code><small>{}</small></div>",
|
||||
crate::escape_html(&column.name),
|
||||
crate::escape_html(&column.data_type),
|
||||
flags.join(" · "),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
format!("<section class=\"pane\"><div class=\"pane-title\"><h2>Columns</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
1
graphs/src/pages/admin/mod.rs
Normal file
1
graphs/src/pages/admin/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod admin;
|
||||
75
graphs/src/pages/import_export/common/csv.rs
Normal file
75
graphs/src/pages/import_export/common/csv.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
pub(crate) fn parse_csv(input: &str) -> Result<Vec<Vec<String>>, 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]);
|
||||
}
|
||||
}
|
||||
59
graphs/src/pages/import_export/common/loader.rs
Normal file
59
graphs/src/pages/import_export/common/loader.rs
Normal file
@@ -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<Profile>,
|
||||
}
|
||||
|
||||
pub(crate) struct Profile {
|
||||
pub name: String,
|
||||
pub tables: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_catalog(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Catalog, LoadError> {
|
||||
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),
|
||||
}
|
||||
3
graphs/src/pages/import_export/common/mod.rs
Normal file
3
graphs/src/pages/import_export/common/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub(crate) mod csv;
|
||||
pub(crate) mod loader;
|
||||
pub(crate) mod schema;
|
||||
55
graphs/src/pages/import_export/common/schema.rs
Normal file
55
graphs/src/pages/import_export/common/schema.rs
Normal file
@@ -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<String> {
|
||||
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<String, String> {
|
||||
schema
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| (column.name.clone(), column.data_type.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn csv_value(raw: &str, data_type: &str) -> Result<Value, String> {
|
||||
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::<i64>()
|
||||
.map_err(|_| format!("Invalid integer value '{raw}'"))?;
|
||||
Kind::NumberValue(integer as f64)
|
||||
} else {
|
||||
Kind::StringValue(raw.to_string())
|
||||
};
|
||||
Ok(Value { kind: Some(kind) })
|
||||
}
|
||||
14
graphs/src/pages/import_export/export/loader.rs
Normal file
14
graphs/src/pages/import_export/export/loader.rs
Normal file
@@ -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<ExportPageState, LoadError> {
|
||||
Ok(ExportPageState {
|
||||
catalog: load_catalog(state, headers).await?,
|
||||
})
|
||||
}
|
||||
164
graphs/src/pages/import_export/export/logic.rs
Normal file
164
graphs/src/pages/import_export/export/logic.rs
Normal file
@@ -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<String>,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
pub(crate) async fn export_page(State(state): State<AppState>, 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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ExportForm>,
|
||||
) -> 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::<Vec<_>>();
|
||||
write_record(&mut csv, &table_headers);
|
||||
}
|
||||
let headers_row = tables.iter().flat_map(|table| table.columns.clone()).collect::<Vec<_>>();
|
||||
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")
|
||||
}
|
||||
14
graphs/src/pages/import_export/export/mod.rs
Normal file
14
graphs/src/pages/import_export/export/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/export", get(logic::export_page))
|
||||
.route("/admin/export.csv", post(logic::export_csv))
|
||||
}
|
||||
31
graphs/src/pages/import_export/export/state.rs
Normal file
31
graphs/src/pages/import_export/export/state.rs
Normal file
@@ -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>), 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::<Vec<_>>();
|
||||
if tables.is_empty() {
|
||||
return Err("Enter at least one table.".to_string());
|
||||
}
|
||||
Ok((profile.to_string(), tables))
|
||||
}
|
||||
}
|
||||
15
graphs/src/pages/import_export/export/ui.rs
Normal file
15
graphs/src/pages/import_export/export/ui.rs
Normal file
@@ -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!("<option value=\"{}\">{}</option>", crate::escape_html(&profile.name), crate::escape_html(&profile.name))).collect::<String>();
|
||||
let tables = page.catalog.profiles.iter().flat_map(|profile| profile.tables.iter()).map(|table| format!("<option value=\"{}\"></option>", crate::escape_html(table))).collect::<String>();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Export CSV</title><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Data transfer</p><h1>Export CSV</h1><p>The download is generated from live table data through gRPC.</p><form method=\"post\" action=\"/admin/export.csv\"><div class=\"form-grid\"><label>Profile<select name=\"profile_name\" required><option value=\"\">Choose a profile</option>{profiles}</select></label><label>Tables<input name=\"table_names\" list=\"export-tables\" required placeholder=\"invoices, customers\"><small>Separate multiple tables with commas.</small></label></div><datalist id=\"export-tables\">{tables}</datalist><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Download CSV</button></div></form></section></main></body></html>"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
format!("<div class=\"form-error\"><strong>Could not export CSV</strong><p>{}</p></div>", crate::escape_html(message))
|
||||
}
|
||||
17
graphs/src/pages/import_export/import/loader.rs
Normal file
17
graphs/src/pages/import_export/import/loader.rs
Normal file
@@ -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<String>,
|
||||
) -> Result<ImportPageState, LoadError> {
|
||||
Ok(ImportPageState { catalog: load_catalog(state, headers).await?, form, error })
|
||||
}
|
||||
240
graphs/src/pages/import_export/import/logic.rs
Normal file
240
graphs/src/pages/import_export/import/logic.rs
Normal file
@@ -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<String>,
|
||||
types: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn import_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
render_loaded(load_page(state, &headers, ImportForm::default(), None).await)
|
||||
}
|
||||
|
||||
pub(crate) async fn import_csv(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ImportForm>,
|
||||
) -> 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::<Vec<_>>();
|
||||
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::<Result<Vec<_>, _>>()
|
||||
{
|
||||
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<Vec<String>>, tables: &[String]) -> Result<(Option<Vec<String>>, Vec<String>, Vec<Vec<String>>), 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<String>], 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<PostTableDataBulkRow, String> {
|
||||
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<super::state::ImportPageState, LoadError>) -> 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);
|
||||
}
|
||||
}
|
||||
15
graphs/src/pages/import_export/import/mod.rs
Normal file
15
graphs/src/pages/import_export/import/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/import", get(logic::import_page))
|
||||
.route("/admin/import", post(logic::import_csv))
|
||||
.layer(DefaultBodyLimit::max(128 * 1024 * 1024))
|
||||
}
|
||||
38
graphs/src/pages/import_export/import/state.rs
Normal file
38
graphs/src/pages/import_export/import/state.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl ImportForm {
|
||||
pub(crate) fn targets(&self) -> Result<(String, Vec<String>), 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::<Vec<_>>();
|
||||
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))
|
||||
}
|
||||
}
|
||||
22
graphs/src/pages/import_export/import/ui.rs
Normal file
22
graphs/src/pages/import_export/import/ui.rs
Normal file
@@ -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!("<option value=\"{}\" {}>{}</option>", crate::escape_html(&profile.name), if page.form.profile_name == profile.name { "selected" } else { "" }, crate::escape_html(&profile.name))).collect::<String>();
|
||||
let tables = page.catalog.profiles.iter().flat_map(|profile| profile.tables.iter()).map(|table| format!("<option value=\"{}\"></option>", crate::escape_html(table))).collect::<String>();
|
||||
let error = page.error.as_deref().map(render_error).unwrap_or_default();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Import CSV</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Data transfer</p><h1>Import CSV</h1><p>Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.</p><form hx-post=\"/admin/import\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\" hx-disabled-elt=\"button[type=submit]\"><div class=\"form-grid\"><label>Profile<select name=\"profile_name\" required><option value=\"\">Choose a profile</option>{profiles}</select></label><label>Target tables<input name=\"table_names\" list=\"import-tables\" value=\"{}\" required placeholder=\"invoices, customers\"></label><label class=\"wide\">CSV file<input type=\"file\" accept=\".csv,text/csv\" onchange=\"this.files[0]?.text().then(value => document.getElementById('csv-data').value = value)\"></label><label class=\"wide\">CSV data<textarea id=\"csv-data\" name=\"csv_data\" rows=\"14\" required>{}</textarea></label></div><datalist id=\"import-tables\">{tables}</datalist><div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Import rows</button></div></form></section></main></body></html>",
|
||||
crate::escape_html(&page.form.table_names),
|
||||
crate::escape_html(&page.form.csv_data),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
format!("<div class=\"form-error\"><strong>Could not import CSV</strong><p>{}</p></div>", crate::escape_html(message))
|
||||
}
|
||||
|
||||
pub(crate) fn render_success(inserted: usize, source_rows: usize, table_count: usize) -> String {
|
||||
format!("<div class=\"form-success\"><strong>Import complete</strong><p>Inserted {inserted} record(s) from {source_rows} CSV row(s) across {table_count} table(s).</p></div>")
|
||||
}
|
||||
11
graphs/src/pages/import_export/mod.rs
Normal file
11
graphs/src/pages/import_export/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new().merge(export::router()).merge(import::router())
|
||||
}
|
||||
5
graphs/src/pages/mod.rs
Normal file
5
graphs/src/pages/mod.rs
Normal file
@@ -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;
|
||||
21
graphs/src/services/mod.rs
Normal file
21
graphs/src/services/mod.rs
Normal file
@@ -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<T>(
|
||||
headers: &HeaderMap,
|
||||
message: T,
|
||||
) -> Result<Request<T>, 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)
|
||||
}
|
||||
57
graphs/static/admin.css
Normal file
57
graphs/static/admin.css
Normal file
@@ -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; } }
|
||||
@@ -64,7 +64,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<main x-data="{ sql: '', copied: false }">
|
||||
<div class="top"><h1>Analytics graphs</h1><a href="/login">Login</a></div>
|
||||
<div class="top"><h1>Analytics graphs</h1><div><a href="/admin">Admin panel</a> · <a href="/login">Login</a></div></div>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="panel sidebar">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login · Analytics graphs</title>
|
||||
<title>Login · Komp Accounting</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@@ -23,13 +23,13 @@
|
||||
<body>
|
||||
<main>
|
||||
<form class="panel" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML" hx-disabled-elt="button" novalidate>
|
||||
<h1>Login</h1>
|
||||
<h1>Sign in</h1>
|
||||
<label>Username or email<input name="identifier" autocomplete="username"></label>
|
||||
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
|
||||
<button type="submit">Login</button>
|
||||
<div id="login-status" aria-live="polite"></div>
|
||||
</form>
|
||||
<a class="back" href="/">Back to analytics</a>
|
||||
<a class="back" href="/admin">Back to admin panel</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user