navbar unified
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,8 +12,6 @@ tui-pages-cli/
|
|||||||
tui-canvas-validation-core/
|
tui-canvas-validation-core/
|
||||||
WEBSITE.md
|
WEBSITE.md
|
||||||
mutants*/
|
mutants*/
|
||||||
AGENT_TESTING_NOTES.md
|
testing/
|
||||||
AGENT_TESTING_NOTES_CLIENT.md
|
|
||||||
AGENT_TESTING_NOTES_WEB.md
|
|
||||||
run_bins/
|
run_bins/
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ pub(crate) async fn load_page(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(AddLogicPageState {
|
Ok(AddLogicPageState {
|
||||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization),
|
||||||
tables,
|
tables,
|
||||||
form,
|
form,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ pub(crate) async fn load_page(
|
|||||||
draft.set_available_relation_table_options(table_options);
|
draft.set_available_relation_table_options(table_options);
|
||||||
|
|
||||||
Ok(AddTablePageState {
|
Ok(AddTablePageState {
|
||||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization),
|
||||||
profiles: tree
|
profiles: tree
|
||||||
.profiles
|
.profiles
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub(crate) async fn load_page(
|
|||||||
.flat_map(|profile| profile.tables.iter().map(|table| table.name.clone()))
|
.flat_map(|profile| profile.tables.iter().map(|table| table.name.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
Ok(ValidationPageState {
|
Ok(ValidationPageState {
|
||||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization),
|
||||||
profiles,
|
profiles,
|
||||||
tables,
|
tables,
|
||||||
form,
|
form,
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ pub(crate) async fn load_admin_page(
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(AdminPageState {
|
Ok(AdminPageState {
|
||||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization),
|
||||||
profiles,
|
profiles,
|
||||||
selected_profile,
|
selected_profile,
|
||||||
tables,
|
tables,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub(crate) async fn load_ecb_page(
|
|||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
||||||
Ok(EcbPageState {
|
Ok(EcbPageState {
|
||||||
nav: crate::ui::Nav::new(headers, "ecb").with_authorization(&authorization),
|
nav: crate::ui::Nav::from_authorization(headers, "ecb", &authorization),
|
||||||
verified_through_date: status.verified_through_date,
|
verified_through_date: status.verified_through_date,
|
||||||
latest_verifiable_date: status.latest_verifiable_date,
|
latest_verifiable_date: status.latest_verifiable_date,
|
||||||
healthy: status.healthy,
|
healthy: status.healthy,
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ pub(crate) async fn load_page(
|
|||||||
inputs.columns.table_name = inputs.selection.table.clone();
|
inputs.columns.table_name = inputs.selection.table.clone();
|
||||||
|
|
||||||
Ok(TableDefinitionPageState {
|
Ok(TableDefinitionPageState {
|
||||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization),
|
||||||
tables,
|
tables,
|
||||||
link_targets,
|
link_targets,
|
||||||
detail,
|
detail,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use crate::{
|
|||||||
AnalyticsTable, ExecuteAnalyticsQueryRequest, GetAnalyticsCatalogRequest,
|
AnalyticsTable, ExecuteAnalyticsQueryRequest, GetAnalyticsCatalogRequest,
|
||||||
GetAnalyticsCatalogResponse, analytics_value,
|
GetAnalyticsCatalogResponse, analytics_value,
|
||||||
},
|
},
|
||||||
auth::GetAuthorizationRequest,
|
|
||||||
definitions::common::Empty,
|
definitions::common::Empty,
|
||||||
services::authenticated_request,
|
services::authenticated_request,
|
||||||
};
|
};
|
||||||
@@ -48,21 +47,6 @@ pub(crate) async fn load_profiles(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn load_navigation(
|
|
||||||
state: AppState,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
) -> crate::ui::Nav {
|
|
||||||
let Ok(request) = authenticated_request(headers, GetAuthorizationRequest {}) else {
|
|
||||||
return crate::ui::Nav::new(headers, "analytics");
|
|
||||||
};
|
|
||||||
let mut auth = state.auth;
|
|
||||||
match auth.get_authorization(request).await {
|
|
||||||
Ok(response) => crate::ui::Nav::new(headers, "analytics")
|
|
||||||
.with_authorization(response.get_ref()),
|
|
||||||
Err(_) => crate::ui::Nav::new(headers, "analytics"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn load_catalog(
|
pub(crate) async fn load_catalog(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ pub(crate) async fn analytics_page(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Html<String> {
|
) -> Html<String> {
|
||||||
Html(ui::render_page(loader::load_navigation(state, &headers).await))
|
Html(ui::render_page(
|
||||||
|
crate::ui::Nav::for_request(state, &headers, "analytics").await,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn load_profiles(
|
pub(crate) async fn load_profiles(
|
||||||
@@ -31,15 +33,15 @@ pub(crate) async fn load_profiles(
|
|||||||
) -> Html<String> {
|
) -> Html<String> {
|
||||||
match loader::load_profiles(state, &headers).await {
|
match loader::load_profiles(state, &headers).await {
|
||||||
Ok(profiles) => Html(ui::render_profile_options(
|
Ok(profiles) => Html(ui::render_profile_options(
|
||||||
crate::ui::Nav::new(&headers, ""),
|
crate::ui::Nav::without_authorization(&headers, ""),
|
||||||
&profiles,
|
&profiles,
|
||||||
)),
|
)),
|
||||||
Err(LoadError::Unauthenticated) => Html(ui::render_profile_options_error(
|
Err(LoadError::Unauthenticated) => Html(ui::render_profile_options_error(
|
||||||
crate::ui::Nav::new(&headers, ""),
|
crate::ui::Nav::without_authorization(&headers, ""),
|
||||||
&tr!(Locale::from_headers(&headers), "analytics-sign-in"),
|
&tr!(Locale::from_headers(&headers), "analytics-sign-in"),
|
||||||
)),
|
)),
|
||||||
Err(LoadError::Backend(message)) => Html(ui::render_profile_options_error(
|
Err(LoadError::Backend(message)) => Html(ui::render_profile_options_error(
|
||||||
crate::ui::Nav::new(&headers, ""),
|
crate::ui::Nav::without_authorization(&headers, ""),
|
||||||
&message,
|
&message,
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,7 @@ pub(crate) async fn load_catalog(
|
|||||||
}
|
}
|
||||||
match loader::load_catalog(state, &headers, input.profile_name).await {
|
match loader::load_catalog(state, &headers, input.profile_name).await {
|
||||||
Ok(catalog) => Html(ui::render_catalog(
|
Ok(catalog) => Html(ui::render_catalog(
|
||||||
crate::ui::Nav::new(&headers, ""),
|
crate::ui::Nav::without_authorization(&headers, ""),
|
||||||
&catalog,
|
&catalog,
|
||||||
))
|
))
|
||||||
.into_response(),
|
.into_response(),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub(crate) async fn load_page(
|
|||||||
) -> Result<ExportPageState, LoadError> {
|
) -> Result<ExportPageState, LoadError> {
|
||||||
let catalog = load_catalog(state, headers, "read").await?;
|
let catalog = load_catalog(state, headers, "read").await?;
|
||||||
Ok(ExportPageState {
|
Ok(ExportPageState {
|
||||||
nav: crate::ui::Nav::new(headers, "").with_authorization(&catalog.authorization),
|
nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization),
|
||||||
catalog,
|
catalog,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub(crate) async fn load_page(
|
|||||||
) -> Result<ImportPageState, LoadError> {
|
) -> Result<ImportPageState, LoadError> {
|
||||||
let catalog = load_catalog(state, headers, "insert").await?;
|
let catalog = load_catalog(state, headers, "insert").await?;
|
||||||
Ok(ImportPageState {
|
Ok(ImportPageState {
|
||||||
nav: crate::ui::Nav::new(headers, "").with_authorization(&catalog.authorization),
|
nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization),
|
||||||
catalog,
|
catalog,
|
||||||
form,
|
form,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -19,14 +19,20 @@ use super::{state::{ChangePasswordInput, LoginInput}, ui};
|
|||||||
pub(crate) async fn login_page(
|
pub(crate) async fn login_page(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Html<String> {
|
) -> Html<String> {
|
||||||
Html(ui::render_page(Nav::new(&headers, "login")))
|
Html(ui::render_page(Nav::without_authorization(&headers, "login")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn password_page(headers: HeaderMap) -> Response {
|
pub(crate) async fn password_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
if authenticated_request(&headers, ()).is_err() {
|
if authenticated_request(&headers, ()).is_err() {
|
||||||
return axum::response::Redirect::to("/login").into_response();
|
return axum::response::Redirect::to("/login").into_response();
|
||||||
}
|
}
|
||||||
Html(ui::render_password_page(Nav::new(&headers, ""))).into_response()
|
Html(ui::render_password_page(
|
||||||
|
Nav::for_request(state, &headers, "").await,
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn change_password(
|
pub(crate) async fn change_password(
|
||||||
|
|||||||
@@ -48,3 +48,33 @@ pub(crate) fn render_password_success(locale: Locale) -> String {
|
|||||||
&tr!(locale, "password-changed-message"),
|
&tr!(locale, "password-changed-message"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::auth::{AuthorizationSnapshot, Permission};
|
||||||
|
|
||||||
|
/// The password page is not itself a nav entry, which is exactly why it
|
||||||
|
/// once rendered with an unresolved `Nav` and lost every capability link
|
||||||
|
/// in the navbar. It carries the same navbar as any other page.
|
||||||
|
#[test]
|
||||||
|
fn the_password_page_carries_the_navbar_the_signed_in_user_has_earned() {
|
||||||
|
let authorization = AuthorizationSnapshot {
|
||||||
|
role: "admin".to_string(),
|
||||||
|
permissions: vec![Permission {
|
||||||
|
object: crate::authz::STRUCT_TABLE.to_string(),
|
||||||
|
action: crate::authz::MANAGE.to_string(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let nav = Nav::from_authorization(
|
||||||
|
&axum::http::HeaderMap::new(),
|
||||||
|
"",
|
||||||
|
&authorization,
|
||||||
|
);
|
||||||
|
|
||||||
|
let rendered = render_password_page(nav);
|
||||||
|
|
||||||
|
assert!(rendered.contains("href=\"/admin\""), "{rendered}");
|
||||||
|
assert!(!rendered.contains("href=\"/login\""), "{rendered}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub(crate) async fn access(
|
|||||||
/// The navbar every permissions page carries. `"permissions"` is a nav entry of
|
/// The navbar every permissions page carries. `"permissions"` is a nav entry of
|
||||||
/// its own, so the section is reachable without going through the admin panel.
|
/// its own, so the section is reachable without going through the admin panel.
|
||||||
pub(crate) fn nav(headers: &HeaderMap, access: &Access) -> crate::ui::Nav {
|
pub(crate) fn nav(headers: &HeaderMap, access: &Access) -> crate::ui::Nav {
|
||||||
crate::ui::Nav::new(headers, "permissions").with_authorization(&access.authorization)
|
crate::ui::Nav::from_authorization(headers, "permissions", &access.authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn roles(
|
pub(crate) async fn roles(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
use super::{state::RegisterInput, ui};
|
use super::{state::RegisterInput, ui};
|
||||||
|
|
||||||
pub(crate) async fn register_page(headers: HeaderMap) -> Html<String> {
|
pub(crate) async fn register_page(headers: HeaderMap) -> Html<String> {
|
||||||
Html(ui::render_page(Nav::new(&headers, "register")))
|
Html(ui::render_page(Nav::without_authorization(&headers, "register")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn register(
|
pub(crate) async fn register(
|
||||||
|
|||||||
@@ -25,10 +25,68 @@ pub(crate) struct Nav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Nav {
|
impl Nav {
|
||||||
|
/// The navbar for a page, resolved in full: this fetches the caller's
|
||||||
|
/// authorization itself, so a handler cannot forget to and end up
|
||||||
|
/// rendering a navbar with every capability link missing.
|
||||||
|
///
|
||||||
/// `active` is the nav link to highlight: `"admin"`, `"permissions"`,
|
/// `active` is the nav link to highlight: `"admin"`, `"permissions"`,
|
||||||
/// `"analytics"`, `"login"`, or `""` for pages that are not themselves nav
|
/// `"analytics"`, `"login"`, or `""` for pages that are not themselves nav
|
||||||
/// entries.
|
/// entries.
|
||||||
pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self {
|
pub(crate) async fn for_request(
|
||||||
|
state: crate::AppState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
active: &'static str,
|
||||||
|
) -> Self {
|
||||||
|
let Ok(request) =
|
||||||
|
crate::services::authenticated_request(headers, crate::auth::GetAuthorizationRequest {})
|
||||||
|
else {
|
||||||
|
return Self::without_authorization(headers, active);
|
||||||
|
};
|
||||||
|
let mut auth = state.auth;
|
||||||
|
match auth.get_authorization(request).await {
|
||||||
|
Ok(response) => Self::from_authorization(headers, active, response.get_ref()),
|
||||||
|
Err(_) => Self::without_authorization(headers, active),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::for_request`] for a handler that already holds the snapshot,
|
||||||
|
/// because gating the page needed it — the same navbar without a second
|
||||||
|
/// round-trip.
|
||||||
|
pub(crate) fn from_authorization(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
active: &'static str,
|
||||||
|
authorization: &crate::auth::AuthorizationSnapshot,
|
||||||
|
) -> Self {
|
||||||
|
let mut nav = Self::without_authorization(headers, active);
|
||||||
|
// The backend answered for this caller, so they are signed in — a
|
||||||
|
// stronger fact than the cookie's mere presence, which is all
|
||||||
|
// `without_authorization` has to go on.
|
||||||
|
nav.authenticated = true;
|
||||||
|
nav.role = authorization.role.clone();
|
||||||
|
nav.can_admin = crate::authz::can_open_admin(authorization);
|
||||||
|
// Permissions is its own nav section, so it is not gated on the admin
|
||||||
|
// panel: managing either roles or users is enough to open it.
|
||||||
|
nav.can_permissions = crate::authz::can_manage(authorization, crate::authz::STRUCT_ROLE)
|
||||||
|
|| crate::authz::can_manage(authorization, crate::authz::STRUCT_USER);
|
||||||
|
nav.can_import = authorization.permissions.iter().any(|permission| {
|
||||||
|
permission.action == "insert" && permission.object.starts_with("data:")
|
||||||
|
});
|
||||||
|
nav.can_export = authorization.permissions.iter().any(|permission| {
|
||||||
|
permission.action == "read" && permission.object.starts_with("data:")
|
||||||
|
});
|
||||||
|
nav.can_ecb = crate::authz::can_read_ecb(authorization);
|
||||||
|
nav
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A navbar with no capabilities at all — every `can_*` link hidden.
|
||||||
|
///
|
||||||
|
/// This is only correct for markup that has no navbar to get wrong: the
|
||||||
|
/// login and register pages, the HTMX fragments that carry a `Nav` purely
|
||||||
|
/// to translate their labels, and the error pages rendered when the
|
||||||
|
/// authorization could not be loaded in the first place. A page rendering
|
||||||
|
/// `ui/navbar.html` for a signed-in user wants [`Self::for_request`];
|
||||||
|
/// reaching for this one there is what leaves the navbar half empty.
|
||||||
|
pub(crate) fn without_authorization(headers: &HeaderMap, active: &'static str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
locale: crate::i18n::Locale::from_headers(headers),
|
locale: crate::i18n::Locale::from_headers(headers),
|
||||||
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
|
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
|
||||||
@@ -91,25 +149,6 @@ impl Nav {
|
|||||||
self.locale.lookup_args(key, &map)
|
self.locale.lookup_args(key, &map)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn with_authorization(
|
|
||||||
mut self,
|
|
||||||
authorization: &crate::auth::AuthorizationSnapshot,
|
|
||||||
) -> Self {
|
|
||||||
self.role = authorization.role.clone();
|
|
||||||
self.can_admin = crate::authz::can_open_admin(authorization);
|
|
||||||
// Permissions is its own nav section, so it is not gated on the admin
|
|
||||||
// panel: managing either roles or users is enough to open it.
|
|
||||||
self.can_permissions = crate::authz::can_manage(authorization, crate::authz::STRUCT_ROLE)
|
|
||||||
|| crate::authz::can_manage(authorization, crate::authz::STRUCT_USER);
|
|
||||||
self.can_import = authorization.permissions.iter().any(|permission| {
|
|
||||||
permission.action == "insert" && permission.object.starts_with("data:")
|
|
||||||
});
|
|
||||||
self.can_export = authorization.permissions.iter().any(|permission| {
|
|
||||||
permission.action == "read" && permission.object.starts_with("data:")
|
|
||||||
});
|
|
||||||
self.can_ecb = crate::authz::can_read_ecb(authorization);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The escaping Askama's `escape` filter would have applied, for the values
|
/// The escaping Askama's `escape` filter would have applied, for the values
|
||||||
|
|||||||
Reference in New Issue
Block a user