web admin panel working well

This commit is contained in:
Priec
2026-07-16 13:25:28 +02:00
parent 3540c028e9
commit d2ac592a49
45 changed files with 2578 additions and 30 deletions

32
Cargo.lock generated
View File

@@ -156,7 +156,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.59.0", "windows-sys 0.52.0",
"x11rb", "x11rb",
] ]
@@ -737,7 +737,7 @@ version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c"
dependencies = [ dependencies = [
"darling 0.23.0", "darling 0.20.11",
"ident_case", "ident_case",
"prettyplease", "prettyplease",
"proc-macro2", "proc-macro2",
@@ -952,7 +952,7 @@ dependencies = [
[[package]] [[package]]
name = "client" name = "client"
version = "0.8.23" version = "0.8.24"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1045,7 +1045,7 @@ dependencies = [
[[package]] [[package]]
name = "common" name = "common"
version = "0.8.23" version = "0.8.24"
dependencies = [ dependencies = [
"prost", "prost",
"prost-build", "prost-build",
@@ -2400,7 +2400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -5113,7 +5113,7 @@ version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.4.1",
"itertools", "itertools",
"log", "log",
"multimap", "multimap",
@@ -5719,7 +5719,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -5803,7 +5803,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]] [[package]]
name = "search" name = "search"
version = "0.8.23" version = "0.8.24"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
@@ -5953,7 +5953,7 @@ dependencies = [
[[package]] [[package]]
name = "server" name = "server"
version = "0.8.23" version = "0.8.24"
dependencies = [ dependencies = [
"analytics-graphs", "analytics-graphs",
"anyhow", "anyhow",
@@ -6202,7 +6202,7 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.4.1",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
@@ -6887,7 +6887,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -7451,7 +7451,7 @@ checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a"
[[package]] [[package]]
name = "tui-canvas" name = "tui-canvas"
version = "0.8.23" version = "0.8.24"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard", "arboard",
@@ -7470,7 +7470,7 @@ dependencies = [
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tui-canvas-validation-core 0.8.23", "tui-canvas-validation-core 0.8.24",
"unicode-width 0.2.2", "unicode-width 0.2.2",
] ]
@@ -7487,7 +7487,7 @@ dependencies = [
[[package]] [[package]]
name = "tui-canvas-validation-core" name = "tui-canvas-validation-core"
version = "0.8.23" version = "0.8.24"
dependencies = [ dependencies = [
"regex", "regex",
"serde", "serde",
@@ -7497,7 +7497,7 @@ dependencies = [
[[package]] [[package]]
name = "tui-pages" name = "tui-pages"
version = "0.8.23" version = "0.8.24"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"nucleo", "nucleo",
@@ -8140,7 +8140,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]

2
client

Submodule client updated: b56c2179dd...9d5d09322f

View File

@@ -1,8 +1,12 @@
# Analytics graphs # Web admin and analytics
A small Rust web server that logs in through `AuthService.Login`, forwards SQL An Axum SSR/HTMX web frontend for the existing komp_ac gRPC backend. It provides
queries to `AnalyticsService.ExecuteAnalyticsQuery`, and displays the streamed the browser version of the client admin panel and the ECharts analytics view.
result with HTMX, Alpine.js, and ECharts. 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 ## Run
@@ -13,8 +17,9 @@ binary. From the parent workspace, start the server normally:
cargo run -p server -- server cargo run -p server -- server
``` ```
Open <http://127.0.0.1:3000/login> to log in, then use the analytics page at Open <http://127.0.0.1:3000/login> to log in. The admin panel is at
<http://127.0.0.1:3000>. The access token is kept in an HTTP-only cookie. The default gRPC endpoint is <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: `http://[::1]:50051`. Both addresses can be changed:
```sh ```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 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 all remaining columns as numeric series, pie uses the second column, and scatter
uses the first two columns. Table view displays every returned value. uses the first two columns. Table view displays every returned value.

View File

@@ -7,6 +7,8 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{get, post}, routing::{get, post},
}; };
mod pages;
mod services;
mod analytics { mod analytics {
include!(concat!( include!(concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
@@ -33,6 +35,30 @@ mod definitions {
"/../common/src/proto/komp_ac.table_definition.rs" "/../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::{ use analytics::{
@@ -44,6 +70,10 @@ use auth::{LoginRequest, auth_service_client::AuthServiceClient};
use definitions::{ use definitions::{
common::Empty, common::Empty,
table_definition::table_definition_client::TableDefinitionClient, 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::{Deserialize, Serialize};
use serde_json::{Value, json}; 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"); const LOGIN_HTML: &str = include_str!("../static/login.html");
#[derive(Clone)] #[derive(Clone)]
struct AppState { pub(crate) struct AppState {
analytics: AnalyticsServiceClient<Channel>, analytics: AnalyticsServiceClient<Channel>,
auth: AuthServiceClient<Channel>, auth: AuthServiceClient<Channel>,
definitions: TableDefinitionClient<Channel>, definitions: TableDefinitionClient<Channel>,
scripts: TableScriptClient<Channel>,
structures: TableStructureServiceClient<Channel>,
validations: TableValidationServiceClient<Channel>,
tables_data: TablesDataClient<Channel>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -124,7 +158,11 @@ pub async fn serve() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let state = AppState { let state = AppState {
analytics: AnalyticsServiceClient::new(channel.clone()), analytics: AnalyticsServiceClient::new(channel.clone()),
auth: AuthServiceClient::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() 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/profiles", get(load_profiles))
.route("/api/catalog", post(load_catalog)) .route("/api/catalog", post(load_catalog))
.route("/api/query", post(run_query)) .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); .with_state(state);
let listener = tokio::net::TcpListener::bind(listen_address).await?; 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(header::SET_COOKIE, cookie);
response response
.headers_mut() .headers_mut()
.insert("hx-redirect", HeaderValue::from_static("/")); .insert("hx-redirect", HeaderValue::from_static("/admin"));
response response
} }
Err(error) => error_fragment(error.message()).into_response(), Err(error) => error_fragment(error.message()).into_response(),

View 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),
}

View 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(),
}
}

View 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))
}

View 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(),
})
}
}

View 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 &quot;subtotal&quot;) (get-var &quot;tax&quot;))\">{}</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>"
)
}

View 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),
}

View 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(),
}
}

View 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))
}

View 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);
}
}

View 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&#10;issued_on: date&#10;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),
)
}

View 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),
}

View 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")
}

View 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))
}

View 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);
}
}

View 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&#10;4,5 | one-of=-,+&#10;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))
}

View 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,
}
}

View 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(),
}
}

View 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))
}

View 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),
}

View 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}");
}
}
}

View File

@@ -0,0 +1 @@
pub(crate) mod admin;

View 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]);
}
}

View 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),
}

View File

@@ -0,0 +1,3 @@
pub(crate) mod csv;
pub(crate) mod loader;
pub(crate) mod schema;

View 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) })
}

View 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?,
})
}

View 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")
}

View 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))
}

View 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))
}
}

View 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))
}

View 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 })
}

View 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);
}
}

View 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))
}

View 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))
}
}

View 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>")
}

View 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
View 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;

View 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
View 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; } }

View File

@@ -64,7 +64,7 @@
</head> </head>
<body> <body>
<main x-data="{ sql: '', copied: false }"> <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"> <div class="workspace">
<aside class="panel sidebar"> <aside class="panel sidebar">

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -23,13 +23,13 @@
<body> <body>
<main> <main>
<form class="panel" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML" hx-disabled-elt="button" novalidate> <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>Username or email<input name="identifier" autocomplete="username"></label>
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label> <label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
<button type="submit">Login</button> <button type="submit">Login</button>
<div id="login-status" aria-live="polite"></div> <div id="login-status" aria-live="polite"></div>
</form> </form>
<a class="back" href="/">Back to analytics</a> <a class="back" href="/admin">Back to admin panel</a>
</main> </main>
</body> </body>
</html> </html>