webs unified

This commit is contained in:
Priec
2026-08-03 13:32:25 +02:00
parent 35d85de556
commit c8afe99d79
59 changed files with 1991 additions and 952 deletions

View File

@@ -49,7 +49,12 @@ pub(crate) async fn load_page(
})
})
.collect();
Ok(AddLogicPageState { tables, form, error })
Ok(AddLogicPageState {
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
tables,
form,
error,
})
}
pub(crate) enum LoadError {

View File

@@ -15,6 +15,7 @@ pub(crate) struct CreateLogicForm {
}
pub(crate) struct AddLogicPageState {
pub nav: crate::ui::Nav,
pub tables: Vec<TableOption>,
pub form: CreateLogicForm,
pub error: Option<String>,

View File

@@ -1,45 +1,34 @@
use askama::Template;
use crate::ui::{Alert, Nav, render};
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),
)
/// GET /admin/logic/new
#[derive(Template)]
#[template(path = "pages/add_logic/add_logic.html")]
struct AddLogicPage<'a> {
nav: Nav,
page: &'a AddLogicPageState,
}
pub(crate) fn render_page(page: &AddLogicPageState) -> String {
render(&AddLogicPage {
nav: page.nav.clone(),
page,
})
}
/// POST /admin/logic — the #submission-status swaps.
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),
)
render(&Alert::error("Could not save the logic", message))
}
pub(crate) fn render_success(id: i64, warnings: &str) -> String {
let warnings = if warnings.trim().is_empty() {
String::new()
let message = if warnings.trim().is_empty() {
format!("Script #{id} was saved.")
} else {
format!("<p><strong>Warnings:</strong> {}</p>", crate::escape_html(warnings))
format!("Script #{id} was saved. Warnings: {warnings}")
};
format!(
"<div class=\"form-success\"><strong>Logic saved</strong><p>Script #{id} was saved.</p>{warnings}</div>"
)
render(&Alert::success("Logic saved", &message))
}

View File

@@ -39,6 +39,7 @@ pub(crate) async fn load_page(
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
Ok(AddTablePageState {
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
profiles: tree.profiles.into_iter().map(|profile| profile.name).collect(),
form,
error,

View File

@@ -23,6 +23,7 @@ pub(crate) struct CreateTableForm {
}
pub(crate) struct AddTablePageState {
pub nav: crate::ui::Nav,
pub profiles: Vec<String>,
pub form: CreateTableForm,
pub error: Option<String>,

View File

@@ -1,40 +1,25 @@
use askama::Template;
use crate::ui::{Alert, Nav, render};
use super::state::AddTablePageState;
const ADMIN_CSS: &str = include_str!("../../../static/admin.css");
/// GET /admin/tables/new
#[derive(Template)]
#[template(path = "pages/add_table/add_table.html")]
struct AddTablePage<'a> {
nav: Nav,
page: &'a AddTablePageState,
}
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;stock: int:quantity-ledger\">{}</textarea><small>One per line: <code>name: type: optional flags</code>. Flags: indexed, half-up, quantity-ledger.</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 columns<input name=\"row_display_columns\" value=\"{}\" placeholder=\"name, ico\"></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_columns),
)
render(&AddTablePage {
nav: page.nav.clone(),
page,
})
}
/// POST /admin/tables — the #submission-status swap.
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),
)
render(&Alert::error("Could not create the table", message))
}

View File

@@ -42,7 +42,14 @@ pub(crate) async fn load_page(
.iter()
.flat_map(|profile| profile.tables.iter().map(|table| table.name.clone()))
.collect();
Ok(ValidationPageState { profiles, tables, form, reusable_rule, error })
Ok(ValidationPageState {
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
profiles,
tables,
form,
reusable_rule,
error,
})
}
pub(crate) async fn load_set_page(
@@ -50,9 +57,10 @@ pub(crate) async fn load_set_page(
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))
) -> Result<(crate::ui::Nav, ValidationSetForm, Option<String>), LoadError> {
// Reuses the rule page load purely for its authorization check and nav.
let page = load_page(state, headers, ValidationForm::default(), true, None).await?;
Ok((page.nav, form, error))
}
pub(crate) enum LoadError {

View File

@@ -119,9 +119,13 @@ pub(crate) async fn save_validation_set(
}
}
fn render_set_loaded(result: Result<(ValidationSetForm, Option<String>), LoadError>) -> Response {
fn render_set_loaded(
result: Result<(crate::ui::Nav, ValidationSetForm, Option<String>), LoadError>,
) -> Response {
match result {
Ok((form, error)) => Html(ui::render_set_page(&form, error.as_deref())).into_response(),
Ok((nav, form, error)) => {
Html(ui::render_set_page(nav, &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(),

View File

@@ -52,6 +52,7 @@ pub(crate) struct ValidationForm {
pub validation_set_name: String,
}
pub(crate) struct ValidationPageState {
pub nav: crate::ui::Nav,
pub profiles: Vec<String>,
pub tables: Vec<String>,
pub form: ValidationForm,

View File

@@ -1,79 +1,52 @@
use askama::Template;
use crate::ui::{Alert, Nav, render};
use super::state::{ValidationPageState, ValidationSetForm};
const ADMIN_CSS: &str = include_str!("../../../static/admin.css");
/// GET /admin/validation/new
#[derive(Template)]
#[template(path = "pages/add_validation/field.html")]
struct FieldValidationPage<'a> {
nav: Nav,
page: &'a ValidationPageState,
}
/// GET /admin/validation/rules/new
#[derive(Template)]
#[template(path = "pages/add_validation/rule.html")]
struct ValidationRulePage<'a> {
nav: Nav,
page: &'a ValidationPageState,
}
/// GET /admin/validation/sets/new
#[derive(Template)]
#[template(path = "pages/add_validation/set.html")]
struct ValidationSetPage<'a> {
nav: Nav,
form: &'a ValidationSetForm,
error: Option<&'a str>,
}
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)),
)
let nav = page.nav.clone();
if page.reusable_rule {
render(&ValidationRulePage { nav, page })
} 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),
)
render(&FieldValidationPage { nav, page })
}
}
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_set_page(nav: Nav, form: &ValidationSetForm, error: Option<&str>) -> String {
render(&ValidationSetPage { nav, form, error })
}
/// The #submission-status swaps for all three validation POST endpoints.
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))
render(&Alert::error("Could not save validation", 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))
render(&Alert::success("Validation saved", message))
}

View File

@@ -121,7 +121,7 @@ pub(crate) async fn load_admin_page(
};
Ok(AdminPageState {
role: authorization.role,
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
profiles,
selected_profile,
tables,

View File

@@ -8,7 +8,7 @@ pub(crate) struct AdminSelection {
#[derive(Debug)]
pub(crate) struct AdminPageState {
pub role: String,
pub nav: crate::ui::Nav,
pub profiles: Vec<ProfileView>,
pub selected_profile: Option<String>,
pub tables: Vec<TableView>,
@@ -38,6 +38,21 @@ pub(crate) struct ColumnView {
pub quantity_ledger: bool,
}
impl ColumnView {
/// The badge list rendered under each column name.
pub(crate) fn flags(&self) -> Vec<&'static str> {
let mut flags = Vec::new();
if self.primary_key {
flags.push("primary key");
}
flags.push(if self.nullable { "nullable" } else { "required" });
if self.quantity_ledger {
flags.push("quantity ledger");
}
flags
}
}
#[derive(Debug)]
pub(crate) enum LoadError {
Unauthenticated,

View File

@@ -1,124 +1,58 @@
use askama::Template;
use crate::ui::{ErrorPage, Nav, render};
use super::state::AdminPageState;
const ADMIN_CSS: &str = include_str!("../../../../static/admin.css");
/// GET /admin
#[derive(Template)]
#[template(path = "pages/admin/admin/admin.html")]
struct AdminPage<'a> {
nav: Nav,
page: &'a AdminPageState,
}
/// GET /admin/workspace — the HTMX swap for the three-pane browser.
#[derive(Template)]
#[template(path = "pages/admin/admin/workspace.html")]
struct AdminWorkspace<'a> {
page: &'a AdminPageState,
}
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),
)
render(&AdminPage {
nav: page.nav.clone(),
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),
)
render(&AdminWorkspace { 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_columns.join(", ")),
)
})
.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.quantity_ledger {
flags.push("quantity ledger");
}
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())
render(&ErrorPage {
nav: Nav::default(),
heading: "Admin panel unavailable",
message,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::admin::admin::state::AdminPageState;
#[test]
fn dashboard_links_every_admin_action() {
let page = AdminPageState {
// The admin page is only reachable with a session, so the navbar
// shows the role badge and the log-out button.
let nav = Nav {
authenticated: true,
role: "admin".to_string(),
active: "admin",
};
let page = AdminPageState {
nav,
profiles: Vec::new(),
selected_profile: None,
tables: Vec::new(),

View File

@@ -0,0 +1,231 @@
//! gRPC calls behind the analytics endpoints, plus the shaping of their
//! responses into the view models the templates render.
use axum::http::HeaderMap;
use crate::{
AppState,
analytics::{
AnalyticsTable, ExecuteAnalyticsQueryRequest, GetAnalyticsCatalogRequest,
GetAnalyticsCatalogResponse, analytics_value,
},
definitions::common::Empty,
services::authenticated_request,
};
use super::state::{
CatalogColumnView, CatalogLinkView, CatalogTableView, CatalogView, ColumnOutput, ProfileOption,
QueryOutput,
};
pub(crate) enum LoadError {
Unauthenticated,
Backend(String),
}
pub(crate) async fn load_profiles(state: AppState) -> Result<Vec<ProfileOption>, String> {
let mut definitions = state.definitions;
let tree = definitions
.get_profile_tree(tonic::Request::new(Empty {}))
.await
.map_err(|error| error.message().to_string())?
.into_inner();
Ok(tree
.profiles
.into_iter()
.map(|profile| ProfileOption {
table_count: profile.tables.len(),
name: profile.name,
})
.collect())
}
pub(crate) async fn load_catalog(
state: AppState,
headers: &HeaderMap,
profile_name: String,
) -> Result<CatalogView, LoadError> {
let request = authenticated_request(headers, GetAnalyticsCatalogRequest { profile_name })
.map_err(|_| LoadError::Unauthenticated)?;
let mut client = state.analytics;
let catalog = client
.get_analytics_catalog(request)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
Ok(catalog_view(&catalog))
}
pub(crate) async fn run_query(
state: AppState,
headers: &HeaderMap,
profile_name: String,
sql: String,
max_rows: u32,
) -> Result<QueryOutput, LoadError> {
let request = authenticated_request(
headers,
ExecuteAnalyticsQueryRequest {
profile_name,
sql,
max_rows,
},
)
.map_err(|_| LoadError::Unauthenticated)?;
let mut client = state.analytics;
let mut stream = client
.execute_analytics_query(request)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
let mut output = QueryOutput {
columns: Vec::new(),
rows: Vec::new(),
row_count: 0,
elapsed_ms: 0,
truncated: false,
};
while let Some(batch) = stream
.message()
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
{
if !batch.columns.is_empty() {
output.columns = batch
.columns
.into_iter()
.map(|column| ColumnOutput {
name: column.name,
data_type: column.data_type,
})
.collect();
}
output.rows.extend(
batch
.rows
.into_iter()
.map(|row| row.values.into_iter().map(value_output).collect()),
);
if batch.is_final {
output.row_count = batch.row_count;
output.elapsed_ms = batch.elapsed_ms;
output.truncated = batch.truncated;
}
}
Ok(output)
}
fn value_output(value: crate::analytics::AnalyticsValue) -> serde_json::Value {
use serde_json::json;
match value.kind {
None | Some(analytics_value::Kind::NullValue(_)) => serde_json::Value::Null,
Some(analytics_value::Kind::BoolValue(value)) => json!(value),
Some(analytics_value::Kind::Int64Value(value)) => json!(value),
Some(analytics_value::Kind::Uint64Value(value)) => json!(value),
Some(analytics_value::Kind::DoubleValue(value)) => json!(value),
Some(analytics_value::Kind::StringValue(value)) => json!(value),
Some(analytics_value::Kind::BytesValue(value)) => serde_json::Value::String(
value.iter().map(|byte| format!("{byte:02x}")).collect(),
),
}
}
fn catalog_view(catalog: &GetAnalyticsCatalogResponse) -> CatalogView {
CatalogView {
tables: catalog.tables.iter().map(catalog_table_view).collect(),
llm_context: build_llm_context(catalog),
}
}
fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView {
CatalogTableView {
name: table.name.clone(),
base_currency: table.base_currency.clone(),
starter_query: format!("SELECT *\nFROM {}\nLIMIT 100;", quote_identifier(&table.name)),
columns: table
.columns
.iter()
.map(|column| {
let mut details = column.field_type.clone();
if column.is_system {
details.push_str(", system");
}
if !column.rounding.is_empty() {
details.push_str(&format!(", rounding {}", column.rounding));
}
CatalogColumnView {
name: column.name.clone(),
insert_text: quote_identifier(&column.name),
details,
}
})
.collect(),
links: table
.links
.iter()
.map(|link| CatalogLinkView {
source_column: link.source_column.clone(),
linked_table: link.linked_table.clone(),
required: link.required,
})
.collect(),
}
}
fn build_llm_context(catalog: &GetAnalyticsCatalogResponse) -> String {
let mut text = format!(
"Write one read-only PostgreSQL SELECT query for the komp_ac analytics API.\n\
Profile: {}\n\n\
Rules:\n\
- Return only the SQL query, without Markdown fences or explanation.\n\
- Use exactly one read-only SELECT statement.\n\
- Use only the public table and column aliases listed below.\n\
- Physical database column names are not visible through this API.\n\
- Add a reasonable LIMIT unless the query is an aggregate with a naturally small result.\n\
- For a chart, put the category or x-axis column first and numeric value columns after it.\n\n\
AVAILABLE ANALYTICS SCHEMA\n",
catalog.profile_name,
);
for table in &catalog.tables {
text.push_str(&format!("\nTABLE {}\n", quote_identifier(&table.name)));
if !table.base_currency.is_empty() {
text.push_str(&format!(" Base currency: {}\n", table.base_currency));
}
text.push_str(" Columns:\n");
for column in &table.columns {
text.push_str(&format!(
" - {}: {}",
quote_identifier(&column.name),
column.field_type,
));
if column.is_system {
text.push_str(" [system]");
}
if !column.rounding.is_empty() {
text.push_str(&format!(" [rounding: {}]", column.rounding));
}
text.push('\n');
}
if !table.links.is_empty() {
text.push_str(" Links:\n");
for link in &table.links {
text.push_str(&format!(
" - {} references {} ({})\n",
quote_identifier(&link.source_column),
quote_identifier(&link.linked_table),
if link.required { "required" } else { "optional" },
));
}
}
}
text
}
fn quote_identifier(identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}

View File

@@ -0,0 +1,80 @@
use axum::{
Form,
extract::State,
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use crate::{
AppState,
ui::{Nav, Notice, render},
};
use super::{
loader::{self, LoadError},
state::{CatalogInput, QueryInput},
ui,
};
pub(crate) async fn analytics_page(headers: HeaderMap) -> Html<String> {
Html(ui::render_page(Nav::new(&headers, "analytics")))
}
pub(crate) async fn load_profiles(State(state): State<AppState>) -> Html<String> {
match loader::load_profiles(state).await {
Ok(profiles) => Html(ui::render_profile_options(&profiles)),
Err(message) => Html(ui::render_profile_options_error(&message)),
}
}
pub(crate) async fn load_catalog(
State(state): State<AppState>,
headers: HeaderMap,
Form(input): Form<CatalogInput>,
) -> Response {
if input.profile_name.trim().is_empty() {
return notice(Notice::error("Enter a profile name to load its schema"));
}
match loader::load_catalog(state, &headers, input.profile_name).await {
Ok(catalog) => Html(ui::render_catalog(&catalog)).into_response(),
Err(error) => load_error(error, "loading the schema"),
}
}
pub(crate) async fn run_query(
State(state): State<AppState>,
headers: HeaderMap,
Form(input): Form<QueryInput>,
) -> Response {
if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() {
return notice(Notice::error("Profile name and SQL are required"));
}
let result = loader::run_query(
state,
&headers,
input.profile_name,
input.sql,
input.max_rows,
)
.await;
match result {
Ok(output) => match ui::render_result(&output, &input.chart_type) {
Ok(html) => Html(html).into_response(),
Err(message) => notice(Notice::error(&message)),
},
Err(error) => load_error(error, "running a query"),
}
}
fn load_error(error: LoadError, action: &str) -> Response {
match error {
LoadError::Unauthenticated => notice(Notice::login_required(&format!(
"You are not signed in for {action}."
))),
LoadError::Backend(message) => notice(Notice::error(&message)),
}
}
fn notice(notice: Notice<'_>) -> Response {
Html(render(&notice)).into_response()
}

View File

@@ -0,0 +1,26 @@
//! The analytics page and the three HTMX endpoints that feed it.
//!
//! GET / → analytics.html
//! GET /api/profiles → profile_options.html
//! POST /api/catalog → catalog.html
//! POST /api/query → query_result.html
use axum::{
Router,
routing::{get, post},
};
use crate::AppState;
pub(crate) mod loader;
pub(crate) mod logic;
pub(crate) mod state;
pub(crate) mod ui;
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/", get(logic::analytics_page))
.route("/api/profiles", get(logic::load_profiles))
.route("/api/catalog", post(logic::load_catalog))
.route("/api/query", post(logic::run_query))
}

View File

@@ -0,0 +1,68 @@
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize)]
pub(crate) struct QueryInput {
pub profile_name: String,
pub sql: String,
#[serde(default = "default_max_rows")]
pub max_rows: u32,
pub chart_type: String,
}
fn default_max_rows() -> u32 {
1000
}
#[derive(Deserialize)]
pub(crate) struct CatalogInput {
pub profile_name: String,
}
/// One entry of the profile `<select>` on the analytics page.
pub(crate) struct ProfileOption {
pub name: String,
pub table_count: usize,
}
pub(crate) struct QueryOutput {
pub columns: Vec<ColumnOutput>,
pub rows: Vec<Vec<Value>>,
pub row_count: u64,
pub elapsed_ms: u64,
pub truncated: bool,
}
pub(crate) struct ColumnOutput {
pub name: String,
#[allow(dead_code)]
pub data_type: String,
}
/// The catalog sidebar, already shaped for `catalog.html` so the
/// template holds no SQL-quoting or label-building logic.
pub(crate) struct CatalogView {
pub tables: Vec<CatalogTableView>,
pub llm_context: String,
}
pub(crate) struct CatalogTableView {
pub name: String,
pub base_currency: String,
pub starter_query: String,
pub columns: Vec<CatalogColumnView>,
pub links: Vec<CatalogLinkView>,
}
pub(crate) struct CatalogColumnView {
pub name: String,
/// The quoted identifier inserted into the SQL box when clicked.
pub insert_text: String,
pub details: String,
}
pub(crate) struct CatalogLinkView {
pub source_column: String,
pub linked_table: String,
pub required: bool,
}

View File

@@ -0,0 +1,160 @@
use askama::Template;
use serde_json::{Value, json};
use crate::ui::{Nav, render};
use super::state::{CatalogView, ColumnOutput, ProfileOption, QueryOutput};
/// GET /
#[derive(Template)]
#[template(path = "pages/analytics/analytics.html")]
struct AnalyticsPage {
nav: Nav,
}
/// GET /api/profiles — the `<option>` list swapped into the profile select.
#[derive(Template)]
#[template(path = "pages/analytics/profile_options.html")]
struct ProfileOptions<'a> {
profiles: &'a [ProfileOption],
error: Option<&'a str>,
}
/// POST /api/catalog
#[derive(Template)]
#[template(path = "pages/analytics/catalog.html")]
struct CatalogFragment<'a> {
tables: &'a [super::state::CatalogTableView],
llm_context: &'a str,
}
/// POST /api/query — a table when `chart_option` is `None`, otherwise an
/// ECharts container carrying the option as a data attribute.
#[derive(Template)]
#[template(path = "pages/analytics/query_result.html")]
struct QueryResult<'a> {
columns: &'a [ColumnOutput],
rows: Vec<Vec<String>>,
row_count: u64,
elapsed_ms: u64,
truncated: bool,
chart_option: Option<String>,
}
pub(crate) fn render_page(nav: Nav) -> String {
render(&AnalyticsPage { nav })
}
pub(crate) fn render_profile_options(profiles: &[ProfileOption]) -> String {
render(&ProfileOptions {
profiles,
error: None,
})
}
pub(crate) fn render_profile_options_error(message: &str) -> String {
render(&ProfileOptions {
profiles: &[],
error: Some(message),
})
}
pub(crate) fn render_catalog(catalog: &CatalogView) -> String {
render(&CatalogFragment {
tables: &catalog.tables,
llm_context: &catalog.llm_context,
})
}
/// `Err` carries a message for the caller to show as a notice — the only
/// failure is a chart type that the result shape cannot satisfy.
pub(crate) fn render_result(result: &QueryOutput, chart_type: &str) -> Result<String, String> {
let chart_option = match chart_type {
"table" => None,
_ => Some(chart_option(result, chart_type)?.to_string()),
};
Ok(render(&QueryResult {
columns: &result.columns,
rows: result
.rows
.iter()
.map(|row| row.iter().map(display_value).collect())
.collect(),
row_count: result.row_count,
elapsed_ms: result.elapsed_ms,
truncated: result.truncated,
chart_option,
}))
}
fn chart_option(result: &QueryOutput, chart_type: &str) -> Result<Value, String> {
if result.columns.len() < 2 {
return Err("Charts require at least two result columns".to_string());
}
let names = result
.columns
.iter()
.map(|column| column.name.clone())
.collect::<Vec<_>>();
Ok(match chart_type {
"pie" => json!({
"tooltip": { "trigger": "item" },
"legend": { "type": "scroll", "bottom": 0 },
"series": [{
"type": "pie",
"radius": ["25%", "68%"],
"data": result.rows.iter().map(|row| json!({
"name": row.first().cloned().unwrap_or(Value::Null),
"value": row.get(1).cloned().unwrap_or(Value::Null),
})).collect::<Vec<_>>()
}]
}),
"scatter" => json!({
"tooltip": { "trigger": "item" },
"xAxis": { "name": names[0], "type": "value" },
"yAxis": { "name": names[1], "type": "value" },
"series": [{
"name": names[1],
"type": "scatter",
"data": result.rows.iter().map(|row| json!([
row.first().cloned().unwrap_or(Value::Null),
row.get(1).cloned().unwrap_or(Value::Null),
])).collect::<Vec<_>>()
}]
}),
"bar" | "line" => {
let series = names
.iter()
.enumerate()
.skip(1)
.map(|(index, name)| json!({
"name": name,
"type": chart_type,
"smooth": chart_type == "line",
"data": result.rows.iter().map(|row| row.get(index).cloned().unwrap_or(Value::Null)).collect::<Vec<_>>(),
}))
.collect::<Vec<_>>();
json!({
"tooltip": { "trigger": "axis" },
"legend": { "type": "scroll", "bottom": 0 },
"grid": { "left": 55, "right": 25, "top": 35, "bottom": 70, "containLabel": true },
"xAxis": {
"type": "category",
"data": result.rows.iter().map(|row| row.first().cloned().unwrap_or(Value::Null)).collect::<Vec<_>>()
},
"yAxis": { "type": "value" },
"series": series,
})
}
_ => return Err("Unknown chart type".to_string()),
})
}
fn display_value(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(value) => value.clone(),
value => value.to_string(),
}
}

View File

@@ -9,6 +9,7 @@ pub(crate) async fn load_page(
headers: &HeaderMap,
) -> Result<ExportPageState, LoadError> {
Ok(ExportPageState {
nav: crate::ui::Nav::new(headers, "admin"),
catalog: load_catalog(state, headers).await?,
})
}

View File

@@ -7,6 +7,7 @@ pub(crate) struct ExportForm {
}
pub(crate) struct ExportPageState {
pub nav: crate::ui::Nav,
pub catalog: super::super::common::loader::Catalog,
}

View File

@@ -1,15 +1,25 @@
use askama::Template;
use crate::ui::{Alert, Nav, render};
use super::state::ExportPageState;
const ADMIN_CSS: &str = include_str!("../../../../static/admin.css");
/// GET /admin/export
#[derive(Template)]
#[template(path = "pages/import_export/export/export.html")]
struct ExportPage<'a> {
nav: Nav,
page: &'a ExportPageState,
}
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>"
)
render(&ExportPage {
nav: page.nav.clone(),
page,
})
}
/// POST /admin/export.csv — shown only when the download cannot be produced.
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))
render(&Alert::error("Could not export CSV", message))
}

View File

@@ -13,5 +13,10 @@ pub(crate) async fn load_page(
form: ImportForm,
error: Option<String>,
) -> Result<ImportPageState, LoadError> {
Ok(ImportPageState { catalog: load_catalog(state, headers).await?, form, error })
Ok(ImportPageState {
nav: crate::ui::Nav::new(headers, "admin"),
catalog: load_catalog(state, headers).await?,
form,
error,
})
}

View File

@@ -9,6 +9,7 @@ pub(crate) struct ImportForm {
}
pub(crate) struct ImportPageState {
pub nav: crate::ui::Nav,
pub catalog: super::super::common::loader::Catalog,
pub form: ImportForm,
pub error: Option<String>,

View File

@@ -1,22 +1,32 @@
use askama::Template;
use crate::ui::{Alert, Nav, render};
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),
)
/// GET /admin/import
#[derive(Template)]
#[template(path = "pages/import_export/import/import.html")]
struct ImportPage<'a> {
nav: Nav,
page: &'a ImportPageState,
}
pub(crate) fn render_page(page: &ImportPageState) -> String {
render(&ImportPage {
nav: page.nav.clone(),
page,
})
}
/// POST /admin/import — the #submission-status swaps.
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))
render(&Alert::error("Could not import CSV", 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>")
let message = format!(
"Inserted {inserted} record(s) from {source_rows} CSV row(s) across {table_count} table(s)."
);
render(&Alert::success("Import complete", &message))
}

View File

@@ -0,0 +1,60 @@
use axum::{
Form,
extract::State,
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
};
use tonic::Request;
use crate::{AppState, auth::LoginRequest, ui::Nav};
use super::{state::LoginInput, ui};
pub(crate) async fn login_page(headers: HeaderMap) -> Html<String> {
Html(ui::render_page(Nav::new(&headers, "login")))
}
pub(crate) async fn login(
State(state): State<AppState>,
Form(input): Form<LoginInput>,
) -> Response {
if input.identifier.trim().is_empty() {
return error(StatusCode::BAD_REQUEST, "Username or email is required");
}
let mut client = state.auth;
let login = match client
.login(Request::new(LoginRequest {
identifier: input.identifier,
password: input.password,
}))
.await
{
Ok(response) => response.into_inner(),
Err(status) => return error(StatusCode::UNAUTHORIZED, status.message()),
};
let cookie = format!(
"{}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}",
crate::ui::SESSION_COOKIE,
login.access_token,
login.expires_in,
);
let Ok(cookie) = HeaderValue::try_from(cookie) else {
return error(
StatusCode::BAD_GATEWAY,
"The server returned an invalid access token",
);
};
let mut response = Html(String::new()).into_response();
response.headers_mut().insert(header::SET_COOKIE, cookie);
response
.headers_mut()
.insert("hx-redirect", HeaderValue::from_static("/admin"));
response
}
fn error(status: StatusCode, message: &str) -> Response {
(status, Html(ui::render_error(message))).into_response()
}

View File

@@ -0,0 +1,16 @@
//! GET /login → login.html
//! POST /login → sets the session cookie, then hx-redirects to /admin
//!
//! The matching POST /logout lives with the admin page, which owns the button.
use axum::{Router, routing::get};
use crate::AppState;
pub(crate) mod logic;
pub(crate) mod state;
pub(crate) mod ui;
pub(crate) fn router() -> Router<AppState> {
Router::new().route("/login", get(logic::login_page).post(logic::login))
}

View File

@@ -0,0 +1,6 @@
#[derive(serde::Deserialize)]
pub(crate) struct LoginInput {
pub identifier: String,
#[serde(default)]
pub password: String,
}

19
web/src/pages/login/ui.rs Normal file
View File

@@ -0,0 +1,19 @@
use askama::Template;
use crate::ui::{Alert, Nav, render};
/// GET /login
#[derive(Template)]
#[template(path = "pages/login/login.html")]
struct LoginPage {
nav: Nav,
}
pub(crate) fn render_page(nav: Nav) -> String {
render(&LoginPage { nav })
}
/// POST /login — the #login-status swap when the credentials are rejected.
pub(crate) fn render_error(message: &str) -> String {
render(&Alert::error("Could not sign in", message))
}

View File

@@ -2,4 +2,6 @@ pub(crate) mod add_logic;
pub(crate) mod add_table;
pub(crate) mod add_validation;
pub(crate) mod admin;
pub(crate) mod analytics;
pub(crate) mod import_export;
pub(crate) mod login;