graphs renamed to web
This commit is contained in:
137
web/src/pages/admin/admin/loader.rs
Normal file
137
web/src/pages/admin/admin/loader.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
definitions::{common::Empty, table_structure::GetTableStructureRequest},
|
||||
services::{AuthenticationError, authenticated_request},
|
||||
};
|
||||
|
||||
use super::state::{
|
||||
AdminPageState, AdminSelection, ColumnView, LoadError, ProfileView, TableView,
|
||||
};
|
||||
|
||||
pub(crate) async fn load_admin_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
selection: AdminSelection,
|
||||
) -> Result<AdminPageState, LoadError> {
|
||||
let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
|
||||
.map_err(authentication_error)?;
|
||||
let mut auth = state.auth;
|
||||
let authorization = auth
|
||||
.get_authorization(authorization_request)
|
||||
.await
|
||||
.map_err(|error| match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
tonic::Code::PermissionDenied => LoadError::Forbidden,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
|
||||
if authorization.role != "admin" {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let mut definitions = state.definitions;
|
||||
let profile_tree = definitions
|
||||
.get_profile_tree(authenticated_request(headers, Empty {}).map_err(authentication_error)?)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
|
||||
let profiles = profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| ProfileView {
|
||||
name: profile.name.clone(),
|
||||
table_count: profile.tables.len(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let selected_profile = (!selection.profile.is_empty()).then_some(selection.profile);
|
||||
let profile = match selected_profile.as_deref() {
|
||||
Some(name) => Some(
|
||||
profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == name)
|
||||
.ok_or_else(|| LoadError::InvalidSelection(format!("Unknown profile '{name}'")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let tables = profile
|
||||
.map(|profile| {
|
||||
profile
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| TableView {
|
||||
name: table.name.clone(),
|
||||
depends_on: table.depends_on.clone(),
|
||||
row_display_columns: table.row_display_columns.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,
|
||||
quantity_ledger: column.quantity_ledger,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(AdminPageState {
|
||||
role: authorization.role,
|
||||
profiles,
|
||||
selected_profile,
|
||||
tables,
|
||||
selected_table,
|
||||
columns,
|
||||
})
|
||||
}
|
||||
|
||||
fn authentication_error(error: AuthenticationError) -> LoadError {
|
||||
match error {
|
||||
AuthenticationError::Missing | AuthenticationError::Invalid => LoadError::Unauthenticated,
|
||||
}
|
||||
}
|
||||
72
web/src/pages/admin/admin/logic.rs
Normal file
72
web/src/pages/admin/admin/logic.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{
|
||||
loader::load_admin_page,
|
||||
state::{AdminSelection, LoadError},
|
||||
ui,
|
||||
};
|
||||
|
||||
pub(crate) async fn admin_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<AdminSelection>,
|
||||
) -> Response {
|
||||
match load_admin_page(state, &headers, selection).await {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_workspace(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<AdminSelection>,
|
||||
) -> Response {
|
||||
match load_admin_page(state, &headers, selection).await {
|
||||
Ok(page) => Html(ui::render_workspace(&page)).into_response(),
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn logout() -> Response {
|
||||
let mut response = Html(String::new()).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_static(
|
||||
"analytics_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
|
||||
),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("hx-redirect", HeaderValue::from_static("/login"));
|
||||
response
|
||||
}
|
||||
|
||||
fn error_response(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
Html(ui::render_error(
|
||||
"This account is not allowed to open the admin panel.",
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
LoadError::InvalidSelection(message) => (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Html(ui::render_error(&message)),
|
||||
)
|
||||
.into_response(),
|
||||
LoadError::Backend(message) => (
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
Html(ui::render_error(&message)),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
18
web/src/pages/admin/admin/mod.rs
Normal file
18
web/src/pages/admin/admin/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
mod loader;
|
||||
mod logic;
|
||||
mod state;
|
||||
mod ui;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin", get(logic::admin_page))
|
||||
.route("/admin/workspace", get(logic::admin_workspace))
|
||||
.route("/logout", post(logic::logout))
|
||||
}
|
||||
47
web/src/pages/admin/admin/state.rs
Normal file
47
web/src/pages/admin/admin/state.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct AdminSelection {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub table: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AdminPageState {
|
||||
pub role: String,
|
||||
pub profiles: Vec<ProfileView>,
|
||||
pub selected_profile: Option<String>,
|
||||
pub tables: Vec<TableView>,
|
||||
pub selected_table: Option<String>,
|
||||
pub columns: Vec<ColumnView>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProfileView {
|
||||
pub name: String,
|
||||
pub table_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TableView {
|
||||
pub name: String,
|
||||
pub depends_on: Vec<String>,
|
||||
pub row_display_columns: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ColumnView {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub nullable: bool,
|
||||
pub primary_key: bool,
|
||||
pub quantity_ledger: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Forbidden,
|
||||
InvalidSelection(String),
|
||||
Backend(String),
|
||||
}
|
||||
141
web/src/pages/admin/admin/ui.rs
Normal file
141
web/src/pages/admin/admin/ui.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use super::state::AdminPageState;
|
||||
|
||||
const ADMIN_CSS: &str = include_str!("../../../../static/admin.css");
|
||||
|
||||
pub(crate) fn render_page(page: &AdminPageState) -> String {
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Admin panel</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong><span class=\"role\">{}</span></div><nav><a class=\"active\" href=\"/admin\">Admin</a><a href=\"/\">Analytics</a><form hx-post=\"/logout\" hx-swap=\"none\"><button class=\"link-button\" type=\"submit\">Log out</button></form></nav></header><main><section class=\"heading\"><div><p class=\"eyebrow\">Workspace</p><h1>Admin panel</h1><p>Browse profiles, tables, and their physical columns.</p></div><div class=\"actions\"><a href=\"/admin/tables/new\">Add table</a><a href=\"/admin/logic/new\">Add logic</a><a href=\"/admin/validation/new\">Add validation</a><a href=\"/admin/validation/sets/new\">Add rule</a><a href=\"/admin/import\">Import</a><a href=\"/admin/export\">Export</a></div></section><div id=\"admin-workspace\">{}</div></main></body></html>",
|
||||
crate::escape_html(&page.role),
|
||||
render_workspace(page),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_workspace(page: &AdminPageState) -> String {
|
||||
format!(
|
||||
"<div class=\"workspace\" aria-live=\"polite\">{}{}{}</div>",
|
||||
render_profiles(page),
|
||||
render_tables(page),
|
||||
render_columns(page),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
format!(
|
||||
"<div class=\"error-page\"><h1>Admin panel unavailable</h1><p>{}</p><a href=\"/admin\">Try again</a></div>",
|
||||
crate::escape_html(message),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_profiles(page: &AdminPageState) -> String {
|
||||
let items = if page.profiles.is_empty() {
|
||||
"<p class=\"empty\">No profiles available.</p>".to_string()
|
||||
} else {
|
||||
page.profiles
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
let selected = page.selected_profile.as_deref() == Some(profile.name.as_str());
|
||||
format!(
|
||||
"<form hx-get=\"/admin/workspace\" hx-target=\"#admin-workspace\" hx-swap=\"innerHTML\"><button class=\"browser-item {}\" type=\"submit\" name=\"profile\" value=\"{}\"><span>{}</span><small>{} tables</small></button></form>",
|
||||
if selected { "selected" } else { "" },
|
||||
crate::escape_html(&profile.name),
|
||||
crate::escape_html(&profile.name),
|
||||
profile.table_count,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
format!("<section class=\"pane\"><div class=\"pane-title\"><h2>Profiles</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", page.profiles.len())
|
||||
}
|
||||
|
||||
fn render_tables(page: &AdminPageState) -> String {
|
||||
let items = if page.selected_profile.is_none() {
|
||||
"<p class=\"empty\">Select a profile to see its tables.</p>".to_string()
|
||||
} else if page.tables.is_empty() {
|
||||
"<p class=\"empty\">This profile has no tables.</p>".to_string()
|
||||
} else {
|
||||
page.tables
|
||||
.iter()
|
||||
.map(|table| {
|
||||
let selected = page.selected_table.as_deref() == Some(table.name.as_str());
|
||||
let dependencies = if table.depends_on.is_empty() {
|
||||
"No dependencies".to_string()
|
||||
} else {
|
||||
format!("Depends on {}", table.depends_on.join(", "))
|
||||
};
|
||||
format!(
|
||||
"<form hx-get=\"/admin/workspace\" hx-target=\"#admin-workspace\" hx-swap=\"innerHTML\"><input type=\"hidden\" name=\"profile\" value=\"{}\"><button class=\"browser-item {}\" type=\"submit\" name=\"table\" value=\"{}\"><span>{}</span><small>{} · display: {}</small></button></form>",
|
||||
crate::escape_html(page.selected_profile.as_deref().unwrap_or_default()),
|
||||
if selected { "selected" } else { "" },
|
||||
crate::escape_html(&table.name),
|
||||
crate::escape_html(&table.name),
|
||||
crate::escape_html(&dependencies),
|
||||
crate::escape_html(&table.row_display_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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pages::admin::admin::state::AdminPageState;
|
||||
|
||||
#[test]
|
||||
fn dashboard_links_every_admin_action() {
|
||||
let page = AdminPageState {
|
||||
role: "admin".to_string(),
|
||||
profiles: Vec::new(),
|
||||
selected_profile: None,
|
||||
tables: Vec::new(),
|
||||
selected_table: None,
|
||||
columns: Vec::new(),
|
||||
};
|
||||
let html = render_page(&page);
|
||||
for route in [
|
||||
"/admin/tables/new",
|
||||
"/admin/logic/new",
|
||||
"/admin/validation/new",
|
||||
"/admin/validation/sets/new",
|
||||
"/admin/import",
|
||||
"/admin/export",
|
||||
"/logout",
|
||||
] {
|
||||
assert!(html.contains(route), "missing admin action route {route}");
|
||||
}
|
||||
}
|
||||
}
|
||||
1
web/src/pages/admin/mod.rs
Normal file
1
web/src/pages/admin/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod admin;
|
||||
Reference in New Issue
Block a user