clean up old files

This commit is contained in:
Brent Schroeter 2025-09-23 13:15:53 -07:00
parent c9b755521e
commit 9bb7dcca7c
21 changed files with 320 additions and 1516 deletions

View file

@ -3,14 +3,29 @@ use axum::{
http::request::Parts,
response::{IntoResponse as _, Redirect, Response},
};
use derive_builder::Builder;
use interim_models::portal::Portal;
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use crate::{app::App, errors::AppError};
pub(crate) trait NavigatorPage {
/// Returns the path component of the URL to the corresonding web page.
/// Starts with a "/" character and the `root_path` if one is specified for
/// [`crate::settings::Settings`].
fn get_path(&self) -> String;
/// Wrap [`Self::get_path`] in an Axum HTTP 303 redirection response for
/// convenience.
fn redirect_to(&self) -> Response {
Redirect::to(&self.get_path()).into_response()
}
}
/// Helper type for semantically generating URI paths, e.g. for redirects.
#[derive(Clone, Debug)]
pub struct Navigator {
pub(crate) struct Navigator {
root_path: String,
sub_path: String,
}
@ -35,6 +50,15 @@ impl Navigator {
}
}
/// Returns a [`NavigatorPage`] builder for navigating to a relation's
/// "settings" page.
pub(crate) fn rel_settings_page(&self) -> RelSettingsPageBuilder {
RelSettingsPageBuilder {
root_path: Some(self.get_root_path()),
..Default::default()
}
}
pub(crate) fn get_root_path(&self) -> String {
self.root_path.to_owned()
}
@ -58,3 +82,31 @@ impl FromRequestParts<App> for Navigator {
})
}
}
#[derive(Builder, Clone, Debug, PartialEq)]
pub(crate) struct RelSettingsPage {
rel_oid: Oid,
#[builder(setter(custom))]
root_path: String,
/// Any value provided for `suffix` will be appended (without %-encoding) to
/// the final path value. This may be used for sub-paths and/or search
/// parameters.
#[builder(default, setter(strip_option))]
suffix: Option<String>,
workspace_id: Uuid,
}
impl NavigatorPage for RelSettingsPage {
fn get_path(&self) -> String {
format!(
"{root_path}/w/{workspace_id}/r/{rel_oid}/settings/{suffix}",
root_path = self.root_path,
workspace_id = self.workspace_id.simple(),
rel_oid = self.rel_oid.0,
suffix = self.suffix.clone().unwrap_or_default(),
)
}
}

View file

@ -1,269 +0,0 @@
use std::net::SocketAddr;
use axum::{
Router,
extract::{ConnectInfo, WebSocketUpgrade, ws::WebSocket},
http::{HeaderValue, header::CACHE_CONTROL},
response::Response,
routing::{any, get, post},
};
use axum_extra::routing::RouterExt as _;
use tower::ServiceBuilder;
use tower_http::{
services::{ServeDir, ServeFile},
set_header::SetResponseHeaderLayer,
};
use crate::{app_state::App, auth, routes};
pub fn new_router(state: App) -> Router<()> {
let root_path = state.settings.root_path.clone();
let app = Router::new()
.route_with_tsr("/workspaces/", get(routes::bases::list_bases_page))
.route_with_tsr("/workspaces/add/", post(routes::bases::add_base_page))
.route_with_tsr(
"/d/{base_id}/config/",
get(routes::bases::base_config_page_get),
)
.route(
"/d/{base_id}/config/",
post(routes::bases::base_config_page_post),
)
.route_with_tsr(
"/d/{base_id}/relations/",
get(routes::relations::list_relations_page),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/",
get(routes::relations::rel_index_page),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/rbac/",
get(routes::relations::rel_rbac_page),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/rbac/invite/",
get(routes::relations::rel_rbac_invite_page_get),
)
.route(
"/d/{base_id}/r/{class_oid}/rbac/invite",
post(routes::relations::rel_rbac_invite_page_post),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/lenses/",
get(routes::lenses::lenses_page),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/lenses/add/",
get(routes::lenses::add_lens_page_get),
)
.route(
"/d/{base_id}/r/{class_oid}/lenses/add/",
post(routes::lenses::add_lens_page_post),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
get(routes::lens_index::lens_page_get),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/get-data",
get(routes::lenses::get_data_page_get),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/add-column",
post(routes::lenses::add_column_page_post),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-value",
post(routes::lenses::update_value_page_post),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/set-filter",
post(routes::lens_set_filter::lens_set_filter_page_post),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/insert",
post(routes::lens_insert::insert_page_post),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/",
get(routes::lenses::viewer_page),
)
.route("/__dev-healthz", any(dev_healthz_handler))
.nest("/auth", auth::new_router())
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.nest_service(
"/js_dist",
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
// FIXME: restore production value
// HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
HeaderValue::from_static("no-cache"),
))
.service(
ServeDir::new("js_dist").not_found_service(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new("static/_404.html")),
),
),
)
.nest_service(
"/glm_dist",
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
// FIXME: restore production value
// HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
HeaderValue::from_static("no-cache"),
))
.service(
ServeDir::new("glm_dist").not_found_service(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new("static/_404.html")),
),
),
)
.nest_service(
"/css_dist",
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
// FIXME: restore production value
// HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
HeaderValue::from_static("no-cache"),
))
.service(
ServeDir::new("css_dist").not_found_service(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new("static/_404.html")),
),
),
)
.fallback_service(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
))
.service(
ServeDir::new("static").not_found_service(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new("static/_404.html")),
),
),
)
.with_state(state);
if root_path.is_empty() {
app
} else {
Router::new().nest(&root_path, app).fallback_service(
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
)
}
}
async fn dev_healthz_handler(
ws: WebSocketUpgrade,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Response {
tracing::info!("{addr} connected");
ws.on_upgrade(move |socket| handle_dev_healthz_socket(socket, addr))
}
async fn handle_dev_healthz_socket(mut socket: WebSocket, _: SocketAddr) {
// Keep socket open indefinitely until the entire server exits
while let Some(Ok(_)) = socket.recv().await {}
}
// #[derive(Deserialize)]
// struct RbacIndexPath {
// oid: u32,
// }
//
// async fn rbac_index(
// State(Settings {
// base_path,
// pg_user_role_prefix: role_prefix,
// ..
// }): State<Settings>,
// DieselConn(diesel_conn): DieselConn,
// PgConn(pg_client): PgConn,
// CurrentUser(current_user): CurrentUser,
// Path(params): Path<RbacIndexPath>,
// ) -> Result<Response, AppError> {
// pg_set_role(&role_prefix, &current_user.id, &pg_client, &diesel_conn)
// .await
// .context("failed to set tokio_postgres role")?;
//
// struct UserDetails {
// user: User,
// roles: Vec<String>,
// }
// let all_users = {
// let role_prefix = role_prefix.clone();
// diesel_conn
// .interact(move |conn| -> Result<_> {
// let pg_users: Vec<PgRole> =
// .get_results(conn)
// .context("failed to query pg users with database access")?;
// let user_ids: Vec<Uuid> = pg_users
// .iter()
// .filter_map(|role| {
// let mut rolname = role.rolname.clone();
// rolname.replace_range(0..role_prefix.len(), "");
// Uuid::parse_str(&rolname).ok()
// })
// .collect();
// let all_users: Vec<User> = users::table
// .filter(users::dsl::id.eq_any(user_ids))
// .get_results(conn)
// .context("failed to query users with database access")?;
// Ok(all_users)
// })
// .await
// .unwrap()?
// };
// #[derive(Template)]
// #[template(path = "rbac.html")]
// struct ResponseTemplate {
// base_path: String,
// role_prefix: String,
// users: Vec<UserDetails>,
// }
//
// Ok(Html(
// ResponseTemplate {
// base_path,
// role_prefix,
// users: all_users
// .into_iter()
// .map(|user| UserDetails {
// user,
// roles: vec![],
// })
// .collect(),
// }
// .render()?,
// )
// .into_response())
// }

View file

@ -1,139 +0,0 @@
use anyhow::Context as _;
use askama::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse as _, Redirect, Response},
};
use axum_extra::extract::Form;
use interim_models::base::Base;
use interim_pgtypes::escape_identifier;
use serde::Deserialize;
use sqlx::{query, query_scalar};
use uuid::Uuid;
use crate::{
app_error::AppError,
app_state::AppDbConn,
base_pooler::{self, WorkspacePooler},
base_user_perms::sync_perms_for_base,
settings::Settings,
user::CurrentUser,
};
pub async fn list_bases_page(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<Response, AppError> {
let bases = Base::with_permission_in(["configure", "connect"])
.for_user(current_user.id)
.fetch_all(&mut app_db)
.await?;
#[derive(Template)]
#[template(path = "list_bases.html")]
struct ResponseTemplate {
bases: Vec<Base>,
settings: Settings,
}
Ok(Html(ResponseTemplate { bases, settings }.render()?).into_response())
}
pub async fn add_base_page(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<Response, AppError> {
// FIXME: CSRF
let base = Base::insertable_builder()
.url("".to_owned())
.owner_id(current_user.id)
.build()?
.insert(&mut app_db)
.await?;
query!(
"
insert into base_user_perms
(id, base_id, user_id, perm)
values ($1, $2, $3, 'configure')",
Uuid::now_v7(),
base.id,
current_user.id
)
.execute(app_db.get_conn())
.await?;
Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base.id)).into_response())
}
#[derive(Deserialize)]
pub struct BaseConfigPagePath {
base_id: Uuid,
}
pub async fn base_config_page_get(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(_current_user): CurrentUser,
Path(params): Path<BaseConfigPagePath>,
) -> Result<Response, AppError> {
// FIXME: auth
let base = Base::with_id(params.base_id).fetch_one(&mut app_db).await?;
#[derive(Template)]
#[template(path = "base_config.html")]
struct ResponseTemplate {
base: Base,
settings: Settings,
}
Ok(Html(ResponseTemplate { base, settings }.render()?).into_response())
}
#[derive(Deserialize)]
pub struct BaseConfigPageForm {
name: String,
url: String,
}
pub async fn base_config_page_post(
State(settings): State<Settings>,
State(mut base_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(BaseConfigPagePath { base_id }): Path<BaseConfigPagePath>,
Form(form): Form<BaseConfigPageForm>,
) -> Result<Response, AppError> {
// FIXME: CSRF
// FIXME: auth
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
query!(
"update bases set name = $1, url = $2 where id = $3",
&form.name,
&form.url,
&base_id
)
.execute(app_db.get_conn())
.await?;
if form.url != base.url {
base_pooler.close_for(base_id).await?;
let rolname = format!("{}{}", base.user_role_prefix, current_user.id.simple());
// Bootstrap user role with database connect privilege. If the user was
// able to successfully authenticate a connection string, it should be
// safe to say that they should be allowed to connect as an Interim
// user.
let mut root_client = base_pooler
.acquire_for(base.id, base_pooler::RoleAssignment::Root)
.await?;
let db_name: String = query_scalar!("select current_database()")
.fetch_one(root_client.get_conn())
.await?
.context("unable to select current_database()")?;
query(&format!(
"grant connect on database {} to {}",
escape_identifier(&db_name),
escape_identifier(&rolname)
))
.execute(root_client.get_conn())
.await?;
sync_perms_for_base(base.id, &mut app_db, &mut root_client).await?;
}
Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base_id)).into_response())
}

View file

@ -1,72 +0,0 @@
use askama::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse as _, Response},
};
use interim_models::{base::Base, expression::PgExpressionAny, lens::Lens};
use interim_pgtypes::pg_attribute::PgAttribute;
use sqlx::postgres::types::Oid;
use crate::{
app_error::AppError,
app_state::AppDbConn,
base_pooler::{RoleAssignment, WorkspacePooler},
navbar::{NavLocation, Navbar, RelLocation},
settings::Settings,
user::CurrentUser,
};
use super::LensPagePath;
pub async fn lens_page_get(
State(settings): State<Settings>,
State(mut base_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
lens_id,
base_id,
class_oid,
}): Path<LensPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let mut base_client = base_pooler
.acquire_for(lens.base_id, RoleAssignment::User(current_user.id))
.await?;
let attrs = PgAttribute::all_for_rel(lens.class_oid)
.fetch_all(&mut base_client)
.await?;
let attr_names: Vec<String> = attrs.iter().map(|attr| attr.attname.clone()).collect();
#[derive(Template)]
#[template(path = "lens.html")]
struct ResponseTemplate {
attr_names: Vec<String>,
filter: Option<PgExpressionAny>,
settings: Settings,
navbar: Navbar,
}
Ok(Html(
ResponseTemplate {
attr_names,
filter: lens.filter.0,
navbar: Navbar::builder()
.root_path(settings.root_path.clone())
.base(base.clone())
.populate_rels(&mut app_db, &mut base_client)
.await?
.current(NavLocation::Rel(
Oid(class_oid),
Some(RelLocation::Lens(lens.id)),
))
.build()?,
settings,
}
.render()?,
)
.into_response())
}

View file

@ -1,95 +0,0 @@
use std::collections::HashMap;
use axum::{
extract::{Path, State},
response::Response,
};
use axum_extra::extract::Form;
use interim_models::{encodable::Encodable, lens::Lens};
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
use sqlx::{postgres::types::Oid, query};
use crate::{
app_error::AppError,
app_state::AppDbConn,
base_pooler::{RoleAssignment, WorkspacePooler},
navigator::Navigator,
user::CurrentUser,
};
use super::LensPagePath;
pub async fn insert_page_post(
State(mut base_pooler): State<WorkspacePooler>,
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
base_id,
class_oid,
lens_id,
}): Path<LensPagePath>,
Form(body): Form<HashMap<String, Vec<String>>>,
) -> Result<Response, AppError> {
// FIXME auth, csrf
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let mut base_client = base_pooler
.acquire_for(base_id, RoleAssignment::User(current_user.id))
.await?;
let rel = PgClass::with_oid(Oid(class_oid))
.fetch_one(&mut base_client)
.await?;
let col_names: Vec<String> = body.keys().cloned().collect();
let col_list_sql = col_names
.iter()
.map(|value| escape_identifier(value))
.collect::<Vec<String>>()
.join(", ");
let n_rows = body.values().map(|value| value.len()).max().unwrap_or(0);
if n_rows > 0 {
let mut param_index = 1;
let mut params: Vec<Encodable> = vec![];
let mut row_list: Vec<String> = vec![];
for i in 0..n_rows {
let mut param_slots: Vec<String> = vec![];
for col in col_names.iter() {
let maybe_value: Option<Encodable> = body
.get(col)
.and_then(|col_values| col_values.get(i))
.map(|value_raw| serde_json::from_str(value_raw))
.transpose()?;
if let Some(value) = maybe_value.filter(|value| !value.is_none()) {
params.push(value);
param_slots.push(format!("${param_index}"));
param_index += 1;
} else {
param_slots.push("default".to_owned());
}
}
row_list.push(format!("({})", param_slots.join(", ")));
}
let row_list_sql = row_list.join(",\n");
let query_sql = &format!(
r#"
insert into {0}.{1}
({col_list_sql})
values
{row_list_sql}
"#,
escape_identifier(&rel.regnamespace),
escape_identifier(&rel.relname)
);
let mut q = query(query_sql);
for param in params {
q = param.bind_onto(q);
}
q.execute(base_client.get_conn()).await?;
}
Ok(navigator.lens_page(&lens).redirect_to())
}

View file

@ -1,36 +0,0 @@
use axum::{extract::Path, response::Response};
use axum_extra::extract::Form;
use interim_models::{expression::PgExpressionAny, lens::Lens};
use serde::Deserialize;
use crate::{app_error::AppError, app_state::AppDbConn, navigator::Navigator, user::CurrentUser};
use super::LensPagePath;
#[derive(Deserialize)]
pub struct FormBody {
filter_expression: Option<String>,
}
pub async fn lens_set_filter_page_post(
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(_): CurrentUser,
Path(LensPagePath { lens_id, .. }): Path<LensPagePath>,
Form(body): Form<FormBody>,
) -> Result<Response, AppError> {
// FIXME auth, csrf
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let filter: Option<PgExpressionAny> =
serde_json::from_str(&body.filter_expression.unwrap_or("null".to_owned()))?;
Lens::update()
.id(lens.id)
.filter(filter)
.build()?
.execute(&mut app_db)
.await?;
Ok(navigator.lens_page(&lens).redirect_to())
}

View file

@ -1,440 +0,0 @@
use std::collections::HashMap;
use askama::Template;
use axum::{
Json,
extract::{Path, State},
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::Form;
use interim_models::{
base::Base,
encodable::Encodable,
field::{Field, InsertableFieldBuilder},
lens::{Lens, LensDisplayType},
presentation::{Presentation, RFC_3339_S},
};
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{
postgres::{PgRow, types::Oid},
query,
};
use uuid::Uuid;
use crate::{
app_error::{AppError, bad_request},
app_state::AppDbConn,
base_pooler::{RoleAssignment, WorkspacePooler},
field_info::FieldInfo,
navigator::Navigator,
settings::Settings,
user::CurrentUser,
};
use super::LensPagePath;
#[derive(Deserialize)]
pub struct LensesPagePath {
base_id: Uuid,
class_oid: u32,
}
pub async fn lenses_page(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let lenses = Lens::belonging_to_base(base_id)
.belonging_to_rel(Oid(class_oid))
.fetch_all(&mut app_db)
.await?;
#[derive(Template)]
#[template(path = "lenses.html")]
struct ResponseTemplate {
base_id: Uuid,
class_oid: u32,
lenses: Vec<Lens>,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
base_id,
class_oid,
lenses,
settings,
}
.render()?,
)
.into_response())
}
pub async fn add_lens_page_get(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
#[derive(Template)]
#[template(path = "add_lens.html")]
struct ResponseTemplate {
base_id: Uuid,
class_oid: u32,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
base_id,
class_oid,
settings,
}
.render()?,
)
.into_response())
}
#[derive(Deserialize)]
pub struct AddLensPagePostForm {
name: String,
}
pub async fn add_lens_page_post(
State(settings): State<Settings>,
State(mut base_pooler): State<WorkspacePooler>,
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
Form(AddLensPagePostForm { name }): Form<AddLensPagePostForm>,
) -> Result<Response, AppError> {
// FIXME auth
// FIXME csrf
let mut client = base_pooler
.acquire_for(base_id, RoleAssignment::User(current_user.id))
.await?;
let attrs = PgAttribute::all_for_rel(Oid(class_oid))
.fetch_all(&mut client)
.await?;
let lens = Lens::insertable_builder()
.base_id(base_id)
.class_oid(Oid(class_oid))
.name(name)
.display_type(LensDisplayType::Table)
.build()?
.insert(&mut app_db)
.await?;
for attr in attrs {
InsertableFieldBuilder::default_from_attr(&attr)
.lens_id(lens.id)
.build()?
.insert(&mut app_db)
.await?;
}
Ok(navigator.lens_page(&lens).redirect_to())
}
pub async fn get_data_page_get(
State(settings): State<Settings>,
State(mut base_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
lens_id,
base_id,
class_oid,
}): Path<LensPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let mut base_client = base_pooler
.acquire_for(lens.base_id, RoleAssignment::User(current_user.id))
.await?;
let rel = PgClass::with_oid(lens.class_oid)
.fetch_one(&mut base_client)
.await?;
let attrs = PgAttribute::all_for_rel(lens.class_oid)
.fetch_all(&mut base_client)
.await?;
let pkey_attrs = PgAttribute::pkeys_for_rel(lens.class_oid)
.fetch_all(&mut base_client)
.await?;
let fields: Vec<FieldInfo> = {
let fields: Vec<Field> = Field::belonging_to_portal(lens.id)
.fetch_all(&mut app_db)
.await?;
let mut field_info: Vec<FieldInfo> = Vec::with_capacity(fields.len());
for field in fields {
if let Some(attr) = attrs.iter().find(|attr| attr.attname == field.name) {
field_info.push(FieldInfo {
field,
has_default: attr.atthasdef,
not_null: attr.attnotnull.unwrap_or_default(),
});
}
}
field_info
};
const FRONTEND_ROW_LIMIT: i64 = 1000;
let mut sql_raw = format!(
"select {0} from {1}.{2}",
pkey_attrs
.iter()
.chain(attrs.iter())
.map(|attr| escape_identifier(&attr.attname))
.collect::<Vec<_>>()
.join(", "),
escape_identifier(&rel.regnamespace),
escape_identifier(&rel.relname),
);
let rows: Vec<PgRow> = if let Some(filter_expr) = lens.filter.0 {
let filter_fragment = filter_expr.into_query_fragment();
let filter_params = filter_fragment.to_params();
sql_raw = format!(
"{sql_raw} where {0} limit ${1}",
filter_fragment.to_sql(1),
filter_params.len() + 1
);
let mut q = query(&sql_raw);
for param in filter_params {
q = param.bind_onto(q);
}
q = q.bind(FRONTEND_ROW_LIMIT);
q.fetch_all(base_client.get_conn()).await?
} else {
sql_raw = format!("{sql_raw} limit $1");
query(&sql_raw)
.bind(FRONTEND_ROW_LIMIT)
.fetch_all(base_client.get_conn())
.await?
};
#[derive(Serialize)]
struct DataRow {
pkey: String,
data: Vec<Encodable>,
}
let mut data_rows: Vec<DataRow> = vec![];
let mut pkeys: Vec<String> = vec![];
for row in rows.iter() {
let mut pkey_values: HashMap<String, Encodable> = HashMap::new();
for attr in pkey_attrs.clone() {
let field = Field::default_from_attr(&attr)
.ok_or(anyhow::anyhow!("unsupported primary key column type"))?;
pkey_values.insert(field.name.clone(), field.get_value_encodable(row)?);
}
let pkey = serde_json::to_string(&pkey_values)?;
pkeys.push(pkey.clone());
let mut row_data: Vec<Encodable> = vec![];
for field in fields.iter() {
row_data.push(field.field.get_value_encodable(row)?);
}
data_rows.push(DataRow {
pkey,
data: row_data,
});
}
#[derive(Serialize)]
struct ResponseBody {
rows: Vec<DataRow>,
fields: Vec<FieldInfo>,
pkeys: Vec<String>,
}
Ok(Json(ResponseBody {
rows: data_rows,
fields,
pkeys,
})
.into_response())
}
#[derive(Debug, Deserialize)]
pub struct AddColumnPageForm {
name: String,
label: String,
presentation_tag: String,
timestamp_format: Option<String>,
}
fn try_presentation_from_form(form: &AddColumnPageForm) -> Result<Presentation, AppError> {
let serialized = match form.presentation_tag.as_str() {
"Timestamp" => {
json!({
"t": form.presentation_tag,
"c": {
"format": form.timestamp_format.clone().unwrap_or(RFC_3339_S.to_owned()),
},
})
}
_ => json!({"t": form.presentation_tag}),
};
serde_json::from_value(serialized).or(Err(bad_request!("unable to parse field type")))
}
pub async fn add_column_page_post(
State(mut base_pooler): State<WorkspacePooler>,
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath { lens_id, .. }): Path<LensPagePath>,
Form(form): Form<AddColumnPageForm>,
) -> Result<Response, AppError> {
// FIXME auth
// FIXME csrf
// FIXME validate column name length is less than 64
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let base = Base::with_id(lens.base_id).fetch_one(&mut app_db).await?;
let mut base_client = base_pooler
.acquire_for(base.id, RoleAssignment::User(current_user.id))
.await?;
let class = PgClass::with_oid(lens.class_oid)
.fetch_one(&mut base_client)
.await?;
let presentation = try_presentation_from_form(&form)?;
let data_type_fragment = presentation.attr_data_type_fragment();
query(&format!(
r#"
alter table {0}
add column if not exists {1} {2}
"#,
class.get_identifier(),
escape_identifier(&form.name),
data_type_fragment
))
.execute(base_client.get_conn())
.await?;
Field::insert()
.lens_id(lens.id)
.name(form.name)
.label(if form.label.is_empty() {
None
} else {
Some(form.label)
})
.presentation(presentation)
.build()?
.insert(&mut app_db)
.await?;
Ok(navigator.lens_page(&lens).redirect_to())
}
// #[derive(Debug, Deserialize)]
// pub struct AddSelectionPageForm {
// column: String,
// }
//
// pub async fn add_selection_page_post(
// State(settings): State<Settings>,
// AppDbConn(mut app_db): AppDbConn,
// CurrentUser(current_user): CurrentUser,
// Path(LensPagePath {
// base_id,
// class_oid,
// lens_id,
// }): Path<LensPagePath>,
// Form(form): Form<AddSelectionPageForm>,
// ) -> Result<Response, AppError> {
// dbg!(&form);
// // FIXME auth
// // FIXME csrf
//
// let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
// .await?
// .ok_or(not_found!("lens not found"))?;
// Selection::insertable_builder()
// .lens_id(lens.id)
// .attr_filters(vec![AttrFilter::NameEq(form.column)])
// .build()?
// .insert(&mut *app_db)
// .await?;
//
// Ok(Redirect::to(&format!(
// "{0}/d/{base_id}/r/{class_oid}/l/{lens_id}/",
// settings.root_path
// ))
// .into_response())
// }
#[derive(Deserialize)]
pub struct UpdateValuePageForm {
column: String,
pkeys: HashMap<String, Encodable>,
value: Encodable,
}
pub async fn update_value_page_post(
State(mut base_pooler): State<WorkspacePooler>,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
base_id, class_oid, ..
}): Path<LensPagePath>,
Json(body): Json<UpdateValuePageForm>,
) -> Result<Response, AppError> {
// FIXME auth
// FIXME csrf
let mut base_client = base_pooler
.acquire_for(base_id, RoleAssignment::User(current_user.id))
.await?;
let rel = PgClass::with_oid(Oid(class_oid))
.fetch_one(&mut base_client)
.await?;
let pkey_attrs = PgAttribute::pkeys_for_rel(rel.oid)
.fetch_all(&mut base_client)
.await?;
body.pkeys
.get(&pkey_attrs.first().unwrap().attname)
.unwrap()
.clone()
.bind_onto(body.value.bind_onto(query(&format!(
r#"update {0}.{1} set {2} = $1 where {3} = $2"#,
escape_identifier(&rel.regnamespace),
escape_identifier(&rel.relname),
escape_identifier(&body.column),
escape_identifier(&pkey_attrs.first().unwrap().attname),
))))
.execute(base_client.get_conn())
.await?;
Ok(Json(json!({ "ok": true })).into_response())
}
#[derive(Deserialize)]
pub struct ViewerPagePath {
base_id: Uuid,
class_oid: u32,
lens_id: Uuid,
}
pub async fn viewer_page(
State(settings): State<Settings>,
State(mut base_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(params): Path<ViewerPagePath>,
) -> Result<Response, AppError> {
todo!("not yet implemented");
}

View file

@ -1,251 +0,0 @@
use std::collections::{HashMap, HashSet};
use anyhow::Context as _;
use askama::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse as _, Redirect, Response},
};
use axum_extra::extract::Form;
use interim_models::{base::Base, rel_invitation::RelInvitation};
use interim_pgtypes::{
pg_acl::{PgAclItem, PgPrivilegeType},
pg_class::{PgClass, PgRelKind},
pg_role::{PgRole, RoleTree},
};
use serde::Deserialize;
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use crate::{
app_error::{AppError, forbidden},
app_state::AppDbConn,
base_pooler::{self, WorkspacePooler},
navbar::{NavLocation, Navbar, RelLocation},
renderable_role_tree::RenderableRoleTree,
settings::Settings,
user::CurrentUser,
};
#[derive(Deserialize)]
pub struct ListRelationsPagePath {
base_id: Uuid,
}
pub async fn list_relations_page(
State(settings): State<Settings>,
State(mut base_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(ListRelationsPagePath { base_id }): Path<ListRelationsPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let rolname = format!("{}{}", &base.user_role_prefix, current_user.id.simple());
let mut client = base_pooler
.acquire_for(base_id, base_pooler::RoleAssignment::User(current_user.id))
.await?;
let roles = PgRole::with_name_in(vec![rolname])
.fetch_all(&mut client)
.await?;
let role = roles.first().context("role not found in pg_roles")?;
let granted_role_tree = RoleTree::granted_to(role.oid)
.fetch_tree(&mut client)
.await?
.context("unable to construct role tree")?;
let granted_roles: HashSet<String> = granted_role_tree
.flatten_inherited()
.into_iter()
.map(|role| role.rolname.clone())
.collect();
let all_rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable])
.fetch_all(&mut client)
.await?;
dbg!(&all_rels);
let accessible_rels: Vec<PgClass> = all_rels
.into_iter()
.filter(|rel| {
let privileges: HashSet<PgPrivilegeType> = rel
.relacl
.clone()
.unwrap_or_default()
.into_iter()
.filter(|item| granted_roles.contains(&item.grantee))
.flat_map(|item| item.privileges)
.map(|privilege| privilege.privilege)
.collect();
privileges.contains(&PgPrivilegeType::Select)
})
.collect();
#[derive(Template)]
#[template(path = "list_rels.html")]
struct ResponseTemplate {
base: Base,
rels: Vec<PgClass>,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
base,
rels: accessible_rels,
settings,
}
.render()?,
)
.into_response())
}
#[derive(Deserialize)]
pub struct RelPagePath {
base_id: Uuid,
class_oid: u32,
}
pub async fn rel_index_page(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
) -> Result<Response, AppError> {
todo!();
}
pub async fn rel_rbac_page(
State(settings): State<Settings>,
State(mut base_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
) -> Result<Response, AppError> {
// FIXME: auth
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let mut base_client = base_pooler
.acquire_for(base_id, base_pooler::RoleAssignment::User(current_user.id))
.await?;
let class = PgClass::with_oid(Oid(class_oid))
.fetch_one(&mut base_client)
.await?;
let owners: RenderableRoleTree = RoleTree::members_of_oid(class.relowner)
.fetch_tree(&mut base_client)
.await?
.ok_or(forbidden!(
"user does not have permission to determine relation owner"
))?
.into();
let all_invites = RelInvitation::belonging_to_rel(Oid(class_oid))
.fetch_all(&mut app_db)
.await?;
let mut invites_by_email: HashMap<String, Vec<RelInvitation>> = HashMap::new();
for invite in all_invites {
let entry = invites_by_email.entry(invite.email.clone()).or_default();
entry.push(invite);
}
struct AclTree {
acl_item: PgAclItem,
grantees: RenderableRoleTree,
}
let mut acl_trees: Vec<AclTree> = vec![];
for item in class.relacl.clone().unwrap_or_default() {
acl_trees.push(AclTree {
acl_item: item.clone(),
grantees: RoleTree::members_of_rolname(&item.grantee)
.fetch_tree(&mut base_client)
.await?
.ok_or(forbidden!(
"unable to construct full acl tree for role {0}",
&item.grantee
))?
.into(),
});
}
#[derive(Template)]
#[template(path = "rel_rbac.html")]
struct ResponseTemplate {
acl_trees: Vec<AclTree>,
base: Base,
invites_by_email: HashMap<String, Vec<RelInvitation>>,
navbar: Navbar,
owners: RenderableRoleTree,
pg_class: PgClass,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
acl_trees,
invites_by_email,
pg_class: class,
navbar: Navbar::builder()
.root_path(settings.root_path.clone())
.base(base.clone())
.populate_rels(&mut app_db, &mut base_client)
.await?
.current(NavLocation::Rel(Oid(class_oid), Some(RelLocation::Rbac)))
.build()?,
owners,
base,
settings,
}
.render()?,
)
.into_response())
}
pub async fn rel_rbac_invite_page_get(
State(settings): State<Settings>,
) -> Result<Response, AppError> {
#[derive(Template)]
#[template(path = "rbac_invite.html")]
struct ResponseTemplate {
settings: Settings,
}
Ok(Html(ResponseTemplate { settings }.render()?).into_response())
}
#[derive(Deserialize)]
pub struct RbacInvitePagePostForm {
email: String,
}
pub async fn rel_rbac_invite_page_post(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
Form(form): Form<RbacInvitePagePostForm>,
) -> Result<Response, AppError> {
// FIXME auth
// FIXME form validation
for privilege in [
PgPrivilegeType::Select,
PgPrivilegeType::Insert,
PgPrivilegeType::Update,
PgPrivilegeType::Delete,
PgPrivilegeType::Truncate,
PgPrivilegeType::References,
PgPrivilegeType::Trigger,
] {
RelInvitation::upsertable()
.email(form.email.clone())
.base_id(base_id)
.class_oid(Oid(class_oid))
.privilege(privilege)
.created_by(current_user.id)
.build()?
.upsert(&mut app_db)
.await?;
}
Ok(Redirect::to(&format!(
"{0}/d/{base_id}/r/{class_oid}/rbac",
settings.root_path
))
.into_response())
}

View file

@ -11,12 +11,21 @@ mod add_portal_handler;
mod get_data_handler;
mod insert_handler;
mod portal_handler;
mod set_filter_handler;
mod settings_invite_handler;
mod update_value_handler;
pub(super) fn new_router() -> Router<App> {
Router::<App>::new()
.route("/settings/invite", post(settings_invite_handler::post))
.route("/add-portal", post(add_portal_handler::post))
.route_with_tsr("/p/{portal_id}/", get(portal_handler::get))
.route_with_tsr("/p/{portal_id}/get-data/", get(get_data_handler::get))
.route("/p/{portal_id}/add-field", post(add_field_handler::post))
.route("/p/{portal_id}/insert", post(insert_handler::post))
.route(
"/p/{portal_id}/update-value",
post(update_value_handler::post),
)
.route("/p/{portal_id}/set-filter", post(set_filter_handler::post))
}

View file

@ -0,0 +1,72 @@
use axum::{debug_handler, extract::Path, response::Response};
// [`axum_extra`]'s form extractor is preferred:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use interim_models::{
expression::PgExpressionAny,
portal::Portal,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use serde::Deserialize;
use uuid::Uuid;
use crate::{
app::{App, AppDbConn},
errors::{AppError, forbidden},
navigator::Navigator,
user::CurrentUser,
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
portal_id: Uuid,
rel_oid: u32,
workspace_id: Uuid,
}
#[derive(Debug, Deserialize)]
pub(super) struct FormBody {
filter_expression: Option<String>,
}
/// HTTP POST handler for applying a [`PgExpressionAny`] filter to a portal's
/// table viewer.
///
/// This handler expects 3 path parameters with the structure described by
/// [`PathParams`].
#[debug_handler(state = App)]
pub(super) async fn post(
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams {
portal_id,
workspace_id,
..
}): Path<PathParams>,
Form(form): Form<FormBody>,
) -> Result<Response, AppError> {
// Check workspace authorization.
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
.fetch_all(&mut app_db)
.await?;
if workspace_perms.iter().all(|p| {
p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect
}) {
return Err(forbidden!("access denied to workspace"));
}
// FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed.
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
let filter: Option<PgExpressionAny> =
serde_json::from_str(&form.filter_expression.unwrap_or("null".to_owned()))?;
Portal::update()
.id(portal.id)
.filter(filter)
.build()?
.execute(&mut app_db)
.await?;
Ok(navigator.portal_page(&portal).redirect_to())
}

View file

@ -0,0 +1,82 @@
use axum::{debug_handler, extract::Path, response::Response};
// [`axum_extra`]'s form extractor is preferred:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use interim_models::{
rel_invitation::RelInvitation,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use interim_pgtypes::pg_acl::PgPrivilegeType;
use serde::Deserialize;
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use crate::{
app::{App, AppDbConn},
errors::{AppError, forbidden},
navigator::{Navigator, NavigatorPage as _},
user::CurrentUser,
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
rel_oid: u32,
workspace_id: Uuid,
}
#[derive(Debug, Deserialize)]
pub(super) struct FormBody {
email: String,
}
/// HTTP POST handler for inviting another Phonograph user to collaborate on a
/// relation, by email address.
#[debug_handler(state = App)]
pub(super) async fn post(
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams {
rel_oid,
workspace_id,
}): Path<PathParams>,
Form(form): Form<FormBody>,
) -> Result<Response, AppError> {
// Check workspace authorization.
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
.fetch_all(&mut app_db)
.await?;
if workspace_perms.iter().all(|p| {
p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect
}) {
return Err(forbidden!("access denied to workspace"));
}
// FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed.
// FIXME form validation
for privilege in [
PgPrivilegeType::Select,
PgPrivilegeType::Insert,
PgPrivilegeType::Update,
PgPrivilegeType::Delete,
PgPrivilegeType::Truncate,
PgPrivilegeType::References,
PgPrivilegeType::Trigger,
] {
RelInvitation::upsertable()
.email(form.email.clone())
.workspace_id(workspace_id)
.class_oid(Oid(rel_oid))
.privilege(privilege)
.created_by(user.id)
.build()?
.upsert(&mut app_db)
.await?;
}
Ok(navigator
.rel_settings_page()
.workspace_id(workspace_id)
.rel_oid(Oid(rel_oid))
.build()?
.redirect_to())
}

View file

@ -0,0 +1,104 @@
use std::collections::HashMap;
use axum::{
Json, debug_handler,
extract::{Path, State},
response::{IntoResponse as _, Response},
};
// [`axum_extra`]'s form extractor is preferred:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use interim_models::{
datum::Datum,
portal::Portal,
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
use serde::Deserialize;
use serde_json::json;
use sqlx::{postgres::types::Oid, query};
use uuid::Uuid;
use crate::{
app::{App, AppDbConn},
errors::{AppError, forbidden},
user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
portal_id: Uuid,
rel_oid: u32,
workspace_id: Uuid,
}
#[derive(Debug, Deserialize)]
pub(super) struct FormBody {
column: String,
pkeys: HashMap<String, Datum>,
value: Datum,
}
/// HTTP POST handler for updating a single value in a backing Postgres table.
///
/// This handler expects 3 path parameters with the structure described by
/// [`PathParams`].
#[debug_handler(state = App)]
pub(super) async fn post(
State(mut workspace_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
Form(form): Form<FormBody>,
) -> Result<Response, AppError> {
// Check workspace authorization.
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
.fetch_all(&mut app_db)
.await?;
if workspace_perms.iter().all(|p| {
p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect
}) {
return Err(forbidden!("access denied to workspace"));
}
// FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed.
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
let workspace = Workspace::with_id(portal.workspace_id)
.fetch_one(&mut app_db)
.await?;
let mut workspace_client = workspace_pooler
.acquire_for(workspace.id, RoleAssignment::User(user.id))
.await?;
let rel = PgClass::with_oid(portal.class_oid)
.fetch_one(&mut workspace_client)
.await?;
let pkey_attrs = PgAttribute::pkeys_for_rel(Oid(rel_oid))
.fetch_all(&mut workspace_client)
.await?;
// TODO: simplify pkey management
form.pkeys
.get(&pkey_attrs.first().unwrap().attname)
.unwrap()
.clone()
.bind_onto(form.value.bind_onto(query(&format!(
"update {ident} set {value_col} = $1 where {pkey_col} = $2",
ident = rel.get_identifier(),
value_col = escape_identifier(&form.column),
pkey_col = escape_identifier(&pkey_attrs.first().unwrap().attname),
))))
.execute(workspace_client.get_conn())
.await?;
Ok(Json(json!({ "ok": true })).into_response())
}

View file

@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block main %}
<form method="post" action="">
<div>
<label for="lens-name-input">Lens name:</label>
<input type="text" name="name" id="lens-name-input">
</div>
<button type="submit">Create</button>
</form>
{% endblock %}

View file

@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block main %}
<form method="post" action="">
<div>
<label for="input-name">Name:</label>
<input type="text" name="name" value="{{ base.name }}">
</div>
<div>
<label for="input-url">Database URL:</label>
<input autocomplete="off" type="text" name="url" value="{{ base.url }}">
</div>
<button type="submit">Save</button>
</form>
{% endblock %}

View file

@ -1,34 +0,0 @@
{% extends "base.html" %}
{% block main %}
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
<table>
<thead>
<tr>
{% for field in fields %}
<th>
<div>{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% for field in fields %}
<td>
{% match Value::get_from_row(row, field.name.as_str()) %}
{% when Ok with (value) %}
{{ value.to_html_string(&field.display_type) | safe }}
{% when Err with (err) %}
<span class="pg-value-error">{{ err }}</span>
{% endmatch %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<lens-controls selections="{{ selections_json }}"></lens-controls>
{% endblock %}

View file

@ -1,19 +0,0 @@
{% extends "base.html" %}
{% block main %}
<table>
<tbody>
{% for lens in lenses %}
<tr>
<td>
<a
href="{{ settings.root_path }}/d/{{ base_id.simple() }}/r/{{ class_oid }}/l/{{ lens.id.simple() }}"
>
{{ lens.name }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -1,9 +1,6 @@
{% extends "base.html" %}
{% block main %}
<form method="post" action="{{ settings.root_path }}/databases/add">
<button type="submit">Add Database</button>
</form>
<table>
<tbody>
{% for base in bases %}

View file

@ -1,17 +0,0 @@
{% extends "base.html" %}
{% block main %}
<table>
<tbody>
{% for rel in rels %}
<tr>
<td>
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ rel.oid.0 }}">
{{ rel.relname }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -1,7 +0,0 @@
<nav>
<ul>
<li>
<a href="{{ settings.root_path }}/auth/login">Login</a>
</li>
</ul>
</nav>

View file

@ -1,87 +0,0 @@
<link rel="stylesheet" href="{{ root_path }}/css_dist/navbar.css">
<nav class="navbar">
<button type="button" class="base-switcher">
<div>{{ base.name }}</div>
{#- TODO: icon #}
</button>
<section>
<h2 class="navbar__heading">Tables</h2>
<menu class="navbar__menu">
{%- for schema in namespaces %}
<li class="navbar__menu-item">
<collapsible-menu class="navbar__collapsible-menu" root-path="{{ root_path }}">
<h3 slot="summary" class="navbar__heading navbar__heading--entity">
{{ schema.name }}
</h3>
<menu slot="content" class="navbar__menu">
{%- for rel in schema.rels %}
<li class="navbar__menu-item
{%- if current == Some(NavLocation::Rel(rel.class_oid.to_owned(), None)) -%}
{# preserve space #} navbar__menu-item--active
{%- endif -%}
">
<collapsible-menu
class="navbar__collapsible-menu"
root-path="{{ root_path }}"
expanded="
{%- if let Some(NavLocation::Rel(rel_oid, _)) = current -%}
{%- if rel_oid.to_owned() == rel.class_oid -%}
true
{%- endif -%}
{%- endif -%}
"
>
<h4 slot="summary" class="navbar__heading navbar__heading--entity">
{{ rel.name }}
</h4>
<menu slot="content" class="navbar__menu">
<li class="navbar__menu-item">
<a
href="{{ root_path }}/d/{{ base.id.simple() }}/r/{{ rel.class_oid.0 }}/rbac"
class="navbar__menu-link"
>
Sharing
</a>
</li>
<li class="navbar__menu-item">
<collapsible-menu class="navbar__collapsible-menu" root-path="{{ root_path }}">
<h5 slot="summary" class="navbar__heading">Tabs</h5>
<menu slot="content" class="navbar__menu">
{% for lens in rel.lenses %}
<li class="navbar__menu-item
">
<a
href="
{{- root_path -}}
/d/
{{- base.id.simple() -}}
/r/
{{- rel.class_oid.0 -}}
/l/
{{- lens.id.simple() -}}
"
class="navbar__menu-link navbar__menu-link--entity
{%- if current == Some(NavLocation::Rel(rel.class_oid.to_owned(), Some(RelLocation::Lens(lens.id.to_owned())))) -%}
{# preserve space #} navbar__menu-link--current
{%- endif -%}
"
>
{{ lens.name }}
</a>
</li>
{% endfor %}
</menu>
</collapsible-menu>
</li>
</menu>
</collapsible-menu>
</li>
{% endfor -%}
</menu>
</collapsible-menu>
</li>
{% endfor -%}
</menu>
</section>
<script type="module" src="{{ root_path }}/js_dist/collapsible-menu.webc.mjs"></script>
</nav>

View file

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% block main %}
<table>
<thead>
<tr>
<th>Name</th>
<th>OID</th>
</tr>
</thead>
<tbody>
{% for relation in relations %}
<tr>
<td>{{ relation.relname }}</td>
<td>{{ relation.oid }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}