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

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
queries to `AnalyticsService.ExecuteAnalyticsQuery`, and displays the streamed
result with HTMX, Alpine.js, and ECharts.
An Axum SSR/HTMX web frontend for the existing komp_ac gRPC backend. It provides
the browser version of the client admin panel and the ECharts analytics view.
The browser never connects to gRPC directly: Axum authenticates with an
HTTP-only cookie, creates typed Tonic requests, and returns full HTML pages or
HTMX fragments.
The backend remains the authority; this crate does not add another HTTP API to it.
## Run
@@ -13,8 +17,9 @@ binary. From the parent workspace, start the server normally:
cargo run -p server -- server
```
Open <http://127.0.0.1:3000/login> to log in, then use the analytics page at
<http://127.0.0.1:3000>. The access token is kept in an HTTP-only cookie. The default gRPC endpoint is
Open <http://127.0.0.1:3000/login> to log in. The admin panel is at
<http://127.0.0.1:3000/admin> and analytics remains at <http://127.0.0.1:3000>.
The access token is kept in an HTTP-only cookie. The default gRPC endpoint is
`http://[::1]:50051`. Both addresses can be changed:
```sh
@@ -24,6 +29,25 @@ cargo run -p server -- server
```
The first SQL result column is used for category labels. Bar and line charts use
## Admin panel
The admin panel mirrors the client workflow with browser-oriented pages:
- profile, table, and physical-column browsing;
- table creation, including indexes, links, money settings, and row labels;
- Steel table-script creation;
- field validations, reusable global rules, validation sets, and set application;
- browser CSV import with live schema validation and chunked bulk insertion;
- CSV downloads for one or multiple tables;
- login, role checks, logout, and navigation to analytics.
CSV files use the same column-header convention as the client. Multi-table CSV
files include a table-name header row before the column-name row. Browser files
are read locally and submitted to Axum; the backend is accessed only through the
existing `TablesData` gRPC service.
## Analytics
all remaining columns as numeric series, pie uses the second column, and scatter
uses the first two columns. Table view displays every returned value.

View File

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

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>
<body>
<main x-data="{ sql: '', copied: false }">
<div class="top"><h1>Analytics graphs</h1><a href="/login">Login</a></div>
<div class="top"><h1>Analytics graphs</h1><div><a href="/admin">Admin panel</a> · <a href="/login">Login</a></div></div>
<div class="workspace">
<aside class="panel sidebar">

View File

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